diff --git a/appinfo/routes.php b/appinfo/routes.php
index ec357a78..be023e86 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -12,6 +12,7 @@
return [
'routes' => [
['name' => 'config#oauthRedirect', 'url' => '/oauth-redirect', 'verb' => 'GET'],
+ ['name' => 'config#getConfig', 'url' => '/config', 'verb' => 'GET'],
['name' => 'config#setConfig', 'url' => '/config', 'verb' => 'PUT'],
['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'],
['name' => 'config#getLocalAddressBooks', 'url' => '/local-addressbooks', 'verb' => 'GET'],
@@ -22,6 +23,11 @@
['name' => 'googleAPI#getContactNumber', 'url' => '/contact-number', 'verb' => 'GET'],
['name' => 'googleAPI#importCalendar', 'url' => '/import-calendar', 'verb' => 'GET'],
['name' => 'googleAPI#importContacts', 'url' => '/import-contacts', 'verb' => 'GET'],
+ ['name' => 'googleAPI#createPickerSession', 'url' => '/picker-session', 'verb' => 'POST'],
+ ['name' => 'googleAPI#getPickerSession', 'url' => '/picker-session', 'verb' => 'GET'],
+ ['name' => 'googleAPI#deletePickerSession', 'url' => '/picker-session', 'verb' => 'DELETE'],
+ ['name' => 'googleAPI#importPhotos', 'url' => '/import-photos', 'verb' => 'POST'],
+ ['name' => 'googleAPI#getImportPhotosInformation', 'url' => '/import-photos-info', 'verb' => 'GET'],
['name' => 'googleAPI#importDrive', 'url' => '/import-files', 'verb' => 'GET'],
['name' => 'googleAPI#getImportDriveInformation', 'url' => '/import-files-info', 'verb' => 'GET'],
]
diff --git a/lib/BackgroundJob/ImportPhotosJob.php b/lib/BackgroundJob/ImportPhotosJob.php
new file mode 100644
index 00000000..01610b2e
--- /dev/null
+++ b/lib/BackgroundJob/ImportPhotosJob.php
@@ -0,0 +1,38 @@
+service->importPhotosJob($userId);
+ }
+}
diff --git a/lib/Controller/ConfigController.php b/lib/Controller/ConfigController.php
index 35b70752..b2763b8c 100644
--- a/lib/Controller/ConfigController.php
+++ b/lib/Controller/ConfigController.php
@@ -17,6 +17,7 @@
use OCA\Google\AppInfo\Application;
use OCA\Google\Service\GoogleAPIService;
use OCA\Google\Service\GoogleDriveAPIService;
+use OCA\Google\Service\GooglePhotosAPIService;
use OCA\Google\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
@@ -41,8 +42,9 @@ class ConfigController extends Controller {
public const CONTACTS_OTHER_SCOPE = 'https://www.googleapis.com/auth/contacts.other.readonly';
public const CALENDAR_SCOPE = 'https://www.googleapis.com/auth/calendar.readonly';
public const CALENDAR_EVENTS_SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly';
+ public const PHOTOS_SCOPE = 'https://www.googleapis.com/auth/photospicker.mediaitems.readonly';
- public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start'];
+ public const INT_CONFIGS = ['nb_imported_files', 'drive_imported_size', 'last_drive_import_timestamp', 'drive_import_job_last_start', 'nb_imported_photos', 'last_photo_import_timestamp', 'photo_import_job_last_start'];
public function __construct(
string $appName,
@@ -55,6 +57,7 @@ public function __construct(
private IInitialState $initialStateService,
private GoogleAPIService $googleApiService,
private GoogleDriveAPIService $googleDriveApiService,
+ private GooglePhotosAPIService $googlePhotosApiService,
private ?string $userId,
private ICrypto $crypto,
private SecretService $secretService,
@@ -62,6 +65,28 @@ public function __construct(
parent::__construct($appName, $request);
}
+ /**
+ * @NoAdminRequired
+ * Get current user config values (used after OAuth popup to refresh state)
+ *
+ * @return DataResponse
+ */
+ public function getConfig(): DataResponse {
+ if ($this->userId === null) {
+ return new DataResponse([], Http::STATUS_BAD_REQUEST);
+ }
+ $userName = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_name', lazy: true);
+ $userScopesString = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_scopes', '{}', lazy: true);
+ $userScopes = json_decode($userScopesString, true);
+ if (!is_array($userScopes)) {
+ $userScopes = [];
+ }
+ return new DataResponse([
+ 'user_name' => $userName,
+ 'user_scopes' => $userScopes,
+ ]);
+ }
+
/**
* @NoAdminRequired
* Set config values
@@ -90,6 +115,11 @@ public function setConfig(array $values): DataResponse {
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'refresh_token');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token_expires_at');
$this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'token');
+ $this->userConfig->deleteUserConfig($this->userId, Application::APP_ID, 'user_scopes');
+ // Cancel any in-progress imports so background jobs don't keep re-queuing
+ // and failing against the now-deleted tokens
+ $this->googleDriveApiService->cancelImport($this->userId);
+ $this->googlePhotosApiService->cancelImport($this->userId);
$result['user_name'] = '';
} else {
if (isset($values['drive_output_dir'])) {
@@ -100,6 +130,9 @@ public function setConfig(array $values): DataResponse {
if (isset($values['importing_drive']) && $values['importing_drive'] === '0') {
$this->googleDriveApiService->cancelImport($this->userId);
}
+ if (isset($values['importing_photos']) && $values['importing_photos'] === '0') {
+ $this->googlePhotosApiService->cancelImport($this->userId);
+ }
}
return new DataResponse($result);
}
@@ -192,6 +225,7 @@ public function oauthRedirect(string $code = '', string $state = '', string $sco
'can_access_contacts' => in_array(self::CONTACTS_SCOPE, $scopes) ? 1 : 0,
'can_access_other_contacts' => in_array(self::CONTACTS_OTHER_SCOPE, $scopes) ? 1 : 0,
'can_access_calendar' => (in_array(self::CALENDAR_SCOPE, $scopes) && in_array(self::CALENDAR_EVENTS_SCOPE, $scopes)) ? 1 : 0,
+ 'can_access_photos' => in_array(self::PHOTOS_SCOPE, $scopes) ? 1 : 0,
];
$this->userConfig->setValueString($this->userId, Application::APP_ID, 'user_scopes', json_encode($scopesArray), lazy: true);
diff --git a/lib/Controller/GoogleAPIController.php b/lib/Controller/GoogleAPIController.php
index 1cf88d50..1b3472f2 100644
--- a/lib/Controller/GoogleAPIController.php
+++ b/lib/Controller/GoogleAPIController.php
@@ -16,6 +16,7 @@
use OCA\Google\Service\GoogleCalendarAPIService;
use OCA\Google\Service\GoogleContactsAPIService;
use OCA\Google\Service\GoogleDriveAPIService;
+use OCA\Google\Service\GooglePhotosAPIService;
use OCA\Google\Service\SecretService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
@@ -30,6 +31,7 @@ public function __construct(
string $appName,
IRequest $request,
private IUserConfig $userConfig,
+ private GooglePhotosAPIService $googlePhotosAPIService,
private GoogleContactsAPIService $googleContactsAPIService,
private GoogleDriveAPIService $googleDriveAPIService,
private GoogleCalendarAPIService $googleCalendarAPIService,
@@ -40,6 +42,106 @@ public function __construct(
$this->accessToken = $this->userId !== null ? $this->secretService->getEncryptedUserValue($this->userId, 'token') : '';
}
+
+ /**
+ * @NoAdminRequired
+ *
+ * @return DataResponse
+ */
+ public function getImportPhotosInformation(): DataResponse {
+ if ($this->accessToken === '') {
+ return new DataResponse([], 400);
+ }
+ $pickerSessionQueue = json_decode(
+ $this->userConfig->getValueString($this->userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true),
+ true,
+ );
+ if (!is_array($pickerSessionQueue)) {
+ $pickerSessionQueue = [];
+ }
+ return new DataResponse([
+ 'importing_photos' => $this->userConfig->getValueString($this->userId, Application::APP_ID, 'importing_photos', lazy: true) === '1',
+ 'last_photo_import_timestamp' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'last_photo_import_timestamp', lazy: true),
+ 'nb_imported_photos' => $this->userConfig->getValueInt($this->userId, Application::APP_ID, 'nb_imported_photos', lazy: true),
+ 'nb_queued_sessions' => count($pickerSessionQueue),
+ ]);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Create a new Google Photos Picker session (Picker API)
+ *
+ * @return DataResponse
+ */
+ public function createPickerSession(): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->createPickerSession($this->userId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Poll a Google Photos Picker session
+ *
+ * @param string $sessionId
+ * @return DataResponse
+ */
+ public function getPickerSession(string $sessionId): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->getPickerSession($this->userId, $sessionId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Delete a Google Photos Picker session
+ *
+ * @param string $sessionId
+ * @return DataResponse
+ */
+ public function deletePickerSession(string $sessionId): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->deletePickerSession($this->userId, $sessionId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
+ /**
+ * @NoAdminRequired
+ *
+ * Start downloading photos from a completed Picker session
+ *
+ * @param string $sessionId
+ * @return DataResponse
+ */
+ public function importPhotos(string $sessionId = ''): DataResponse {
+ if ($this->accessToken === '' || $this->userId === null) {
+ return new DataResponse([], 400);
+ }
+ $result = $this->googlePhotosAPIService->startImportPhotos($this->userId, $sessionId);
+ if (isset($result['error'])) {
+ return new DataResponse($result['error'], 401);
+ }
+ return new DataResponse($result);
+ }
+
/**
* @NoAdminRequired
*
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index 8c06fc95..4740787d 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -62,6 +62,18 @@ public function prepare(INotification $notification, string $languageCode): INot
$l = $this->factory->get('integration_google', $languageCode);
switch ($notification->getSubject()) {
+ case 'import_photos_finished':
+ /** @var array{nbImported?:string, targetPath: string} $p */
+ $p = $notification->getSubjectParameters();
+ $nbImported = (int)($p['nbImported'] ?? 0);
+ $targetPath = $p['targetPath'];
+ $content = $l->n('%n photo was imported from Google.', '%n photos were imported from Google.', $nbImported);
+
+ $notification->setParsedSubject($content)
+ ->setIcon($this->url->getAbsoluteURL($this->url->imagePath(Application::APP_ID, 'app-dark.svg')))
+ ->setLink($this->url->linkToRouteAbsolute('files.view.index', ['dir' => $targetPath]));
+ return $notification;
+
case 'import_drive_finished':
/** @var array{nbImported?:string, targetPath: string} $p */
$p = $notification->getSubjectParameters();
diff --git a/lib/Service/GoogleAPIService.php b/lib/Service/GoogleAPIService.php
index ee2b6cc8..247baaf9 100644
--- a/lib/Service/GoogleAPIService.php
+++ b/lib/Service/GoogleAPIService.php
@@ -20,9 +20,14 @@
use OCA\Google\AppInfo\Application;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Config\IUserConfig;
+use OCP\Files\Folder;
+use OCP\Files\InvalidPathException;
+use OCP\Files\NotPermittedException;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IL10N;
+use OCP\Lock\ILockingProvider;
+use OCP\Lock\LockedException;
use OCP\Notification\IManager as INotificationManager;
use Psr\Log\LoggerInterface;
use Throwable;
@@ -353,6 +358,83 @@ public function simpleDownload(string $userId, string $url, $resource, array $pa
}
}
+ /**
+ * Download content from a URL and save it as a new file in a folder.
+ * Handles resource management, timestamp setting, and cleanup on failure.
+ *
+ * @param string $userId
+ * @param Folder $saveFolder Target Nextcloud folder
+ * @param string $fileName Name of the file to create
+ * @param string $fileUrl URL to download from
+ * @param int|null $mtime Unix timestamp for the file modification time; uses current time if null
+ * @param array $params Additional HTTP query parameters
+ * @return int|null downloaded file size in bytes, or null on failure
+ */
+ public function downloadAndSaveFile(
+ string $userId,
+ Folder $saveFolder,
+ string $fileName,
+ string $fileUrl,
+ ?int $mtime = null,
+ array $params = [],
+ ): ?int {
+ try {
+ $savedFile = $saveFolder->newFile($fileName);
+ } catch (NotPermittedException|InvalidPathException $e) {
+ return null;
+ }
+
+ try {
+ $resource = $savedFile->fopen('w');
+ } catch (LockedException $e) {
+ if ($savedFile->isDeletable()) {
+ try {
+ $savedFile->delete();
+ } catch (\Throwable $e) {
+ }
+ }
+ return null;
+ }
+ if ($resource === false) {
+ if ($savedFile->isDeletable()) {
+ try {
+ $savedFile->delete();
+ } catch (\Throwable $e) {
+ }
+ }
+ return null;
+ }
+
+ $res = $this->simpleDownload($userId, $fileUrl, $resource, $params);
+ if (!isset($res['error'])) {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ if ($mtime !== null) {
+ $savedFile->touch($mtime);
+ } else {
+ $savedFile->touch();
+ }
+ $stat = $savedFile->stat();
+ return (int)($stat['size'] ?? 0);
+ } else {
+ if (is_resource($resource)) {
+ fclose($resource);
+ }
+ if ($savedFile->isDeletable()) {
+ try {
+ $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
+ } catch (\Throwable $e) {
+ }
+ try {
+ $savedFile->delete();
+ } catch (\Throwable $e) {
+ }
+ }
+ }
+ return null;
+ }
+
private function checkTokenExpiration(string $userId): void {
$refreshToken = $this->secretService->getEncryptedUserValue($userId, 'refresh_token');
$expireAt = $this->userConfig->getValueInt($userId, Application::APP_ID, 'token_expires_at', lazy: true);
diff --git a/lib/Service/GoogleDriveAPIService.php b/lib/Service/GoogleDriveAPIService.php
index 39e0d678..5744f3c0 100644
--- a/lib/Service/GoogleDriveAPIService.php
+++ b/lib/Service/GoogleDriveAPIService.php
@@ -25,7 +25,6 @@
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
-use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use OCP\PreConditionNotMetException;
use Psr\Log\LoggerInterface;
@@ -155,6 +154,8 @@ public function startImportDrive(string $userId): array {
public function cancelImport(string $userId): void {
$this->jobList->remove(ImportDriveJob::class, ['user_id' => $userId]);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_drive', '0', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'drive_import_running', '0', lazy: true);
}
/**
@@ -550,62 +551,6 @@ private function createDirsUnder(array &$directoriesById, Folder $currentFolder,
/**
* Create new file in the given folder with given filename
- * Download contents of the file from Google Drive and save it into the created file
- * @param Folder $saveFolder
- * @param string $fileName
- * @param string $userId
- * @param string $fileUrl
- * @param array $fileItem
- * @param array $params
- * @return ?int downloaded size, null if error during file creation or download
- * @throws InvalidPathException
- * @throws LockedException
- * @throws NotFoundException
- * @throws NotPermittedException
- */
- private function downloadAndSaveFile(
- Folder $saveFolder, string $fileName, string $userId,
- string $fileUrl, array $fileItem, array $params = [],
- ): ?int {
- try {
- $savedFile = $saveFolder->newFile($fileName);
- } catch (NotPermittedException $e) {
- return null;
- }
-
- try {
- $resource = $savedFile->fopen('w');
- } catch (LockedException $e) {
- return null;
- }
- if ($resource === false) {
- return null;
- }
-
- $res = $this->googleApiService->simpleDownload($userId, $fileUrl, $resource, $params);
- if (!isset($res['error'])) {
- if (is_resource($resource)) {
- fclose($resource);
- }
- if (isset($fileItem['modifiedTime'])) {
- $d = new DateTime($fileItem['modifiedTime']);
- $ts = $d->getTimestamp();
- $savedFile->touch($ts);
- } else {
- $savedFile->touch();
- }
- $stat = $savedFile->stat();
- return $stat['size'] ?? 0;
- } else {
- if ($savedFile->isDeletable()) {
- $savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
- $savedFile->delete();
- }
- }
- return null;
- }
-
- /**
* @param array $fileItem
* @param string $userId
* @param bool $hasNameConflict
@@ -692,12 +637,21 @@ private function getDocumentRequestParams(string $mimeType, string $documentForm
* @throws NotPermittedException
*/
private function getFile(string $userId, array $fileItem, Folder $saveFolder, string $fileName): ?int {
+ $mtime = null;
+ if (isset($fileItem['modifiedTime'])) {
+ try {
+ $mtime = (new DateTime($fileItem['modifiedTime']))->getTimestamp();
+ } catch (Exception $e) {
+ // fall through: use null (current time)
+ }
+ }
+
if (in_array($fileItem['mimeType'], array_values(self::DOCUMENT_MIME_TYPES))) {
$documentFormat = $this->getUserDocumentFormat($userId);
// potentially a doc
$params = $this->getDocumentRequestParams($fileItem['mimeType'], $documentFormat);
$fileUrl = 'https://www.googleapis.com/drive/v3/files/' . urlencode((string)$fileItem['id']) . '/export';
- $result = $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem, $params);
+ $result = $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime, $params);
if ($result !== null) {
return $result;
}
@@ -727,11 +681,11 @@ private function getFile(string $userId, array $fileItem, Folder $saveFolder, st
}
}
$this->logger->debug('Document export succeeded', ['fileItem' => $fileItem, 'fileUrl' => $fileUrl]);
- return $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem);
+ return $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime);
} elseif (isset($fileItem['webContentLink'])) {
// classic file
$fileUrl = 'https://www.googleapis.com/drive/v3/files/' . urlencode((string)$fileItem['id']) . '?alt=media';
- return $this->downloadAndSaveFile($saveFolder, $fileName, $userId, $fileUrl, $fileItem);
+ return $this->googleApiService->downloadAndSaveFile($userId, $saveFolder, $fileName, $fileUrl, $mtime);
}
return null;
}
diff --git a/lib/Service/GooglePhotosAPIService.php b/lib/Service/GooglePhotosAPIService.php
new file mode 100644
index 00000000..69f2d2dd
--- /dev/null
+++ b/lib/Service/GooglePhotosAPIService.php
@@ -0,0 +1,454 @@
+, expireTime?:string, error?:mixed}
+ */
+ public function createPickerSession(string $userId): array {
+ $result = $this->googleApiService->request(
+ $userId,
+ 'v1/sessions',
+ ['pickingConfig' => ['maxItemCount' => 2000]],
+ 'POST',
+ self::PICKER_BASE_URL,
+ );
+ if (isset($result['error'])) {
+ /** @psalm-suppress InvalidReturnStatement */
+ return $result;
+ }
+ // append /autoclose so Google Photos closes its window after selection is done
+ if (isset($result['pickerUri'])) {
+ $result['pickerUri'] .= '/autoclose';
+ }
+ /** @psalm-suppress InvalidReturnStatement */
+ return $result;
+ }
+
+ /**
+ * Poll an existing Picker session to check if the user has finished selecting
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @return array{mediaItemsSet?:bool, pollingConfig?:array, expireTime?:string, error?:string}
+ */
+ public function getPickerSession(string $userId, string $sessionId): array {
+ return $this->googleApiService->request(
+ $userId,
+ 'v1/sessions/' . urlencode($sessionId),
+ [],
+ 'GET',
+ self::PICKER_BASE_URL,
+ );
+ }
+
+ /**
+ * Delete a Picker session (cleanup after import)
+ *
+ * Only clears the stored picker_session_id when it matches the session being deleted,
+ * so deleting a UI/queued session does not corrupt the active import session ID.
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @return array
+ */
+ public function deletePickerSession(string $userId, string $sessionId): array {
+ $result = $this->googleApiService->request(
+ $userId,
+ 'v1/sessions/' . urlencode($sessionId),
+ [],
+ 'DELETE',
+ self::PICKER_BASE_URL,
+ );
+ $storedSessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ if ($storedSessionId === $sessionId) {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ }
+ return $result;
+ }
+
+ /**
+ * Start a background import job for the given Picker session
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @return array{targetPath?:string, error?:string, queued?:true}
+ */
+ public function startImportPhotos(string $userId, string $sessionId): array {
+ if (trim($sessionId) === '') {
+ return ['error' => 'No picker session ID provided'];
+ }
+
+ $targetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true);
+ $targetPath = $targetPath ?: '/Google Photos';
+
+ $alreadyImporting = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1';
+ if ($alreadyImporting) {
+ // Queue this session to run after the current one finishes
+ $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ $queue = json_decode($queueRaw, true);
+ if (!is_array($queue)) {
+ $queue = [];
+ }
+ $queue[] = $sessionId;
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true);
+ return ['targetPath' => $targetPath, 'queued' => true];
+ }
+
+ // create root folder
+ $userFolder = $this->root->getUserFolder($userId);
+ if (!$userFolder->nodeExists($targetPath)) {
+ $userFolder->newFolder($targetPath);
+ } else {
+ $folder = $userFolder->get($targetPath);
+ if (!($folder instanceof Folder)) {
+ return ['error' => 'Impossible to create Google Photos folder'];
+ }
+ }
+
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '1', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $sessionId, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ return ['targetPath' => $targetPath];
+ }
+
+ /**
+ * Cancel any pending import and optionally delete the active picker session
+ *
+ * @param string $userId
+ * @return void
+ */
+ public function cancelImport(string $userId): void {
+ $this->jobList->remove(ImportPhotosJob::class, ['user_id' => $userId]);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
+ $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ if ($sessionId !== '') {
+ $this->deletePickerSession($userId, $sessionId);
+ }
+ // Also delete all queued sessions so they don't remain as stale open sessions in Google
+ $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ $queue = json_decode($queueRaw, true);
+ if (is_array($queue)) {
+ foreach ($queue as $queuedSessionId) {
+ if (is_string($queuedSessionId) && $queuedSessionId !== '') {
+ $this->deletePickerSession($userId, $queuedSessionId);
+ }
+ }
+ }
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ }
+
+ /**
+ * Background job entry point: import a batch of photos then re-queue if unfinished
+ *
+ * @param string $userId
+ * @return void
+ */
+ public function importPhotosJob(string $userId): void {
+ $this->logger->debug('Importing photos (Picker API) for ' . $userId);
+
+ $this->userScopeService->setUserScope($userId);
+ $this->userScopeService->setFilesystemScope($userId);
+
+ $importingPhotos = $this->userConfig->getValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true) === '1';
+ if (!$importingPhotos) {
+ // Clear the concurrency guard so it does not delay future imports
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', 0, lazy: true);
+ return;
+ }
+
+ $jobRunning = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true) === '1';
+ $nowTs = (new DateTime())->getTimestamp();
+ if ($jobRunning) {
+ $lastJobStart = $this->userConfig->getValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', lazy: true);
+ if ($lastJobStart !== 0 && ($nowTs - $lastJobStart < Application::IMPORT_JOB_TIMEOUT)) {
+ $this->logger->info(
+ 'Last job execution (' . strval($nowTs - $lastJobStart) . ') is less than '
+ . strval(Application::IMPORT_JOB_TIMEOUT) . ' seconds ago, delaying execution',
+ );
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ return;
+ }
+ }
+
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '1', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'photo_import_job_last_start', $nowTs, lazy: true);
+
+ $targetPath = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true);
+ $targetPath = $targetPath ?: '/Google Photos';
+ $sessionId = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_id', '', lazy: true);
+ $alreadyImported = $this->userConfig->getValueInt($userId, Application::APP_ID, 'nb_imported_photos', lazy: true);
+
+ try {
+ $result = $this->importFromPickerSession($userId, $sessionId, $targetPath, 500000000, $alreadyImported);
+ } catch (Exception|Throwable $e) {
+ $result = ['error' => 'Unknown job failure. ' . $e->getMessage()];
+ }
+
+ if (isset($result['error']) || (isset($result['finished']) && $result['finished'])) {
+ // Clean up the picker session in both success and error cases
+ if ($sessionId !== '') {
+ $this->deletePickerSession($userId, $sessionId);
+ }
+ if (isset($result['finished']) && $result['finished']) {
+ $this->googleApiService->sendNCNotification($userId, 'import_photos_finished', [
+ 'nbImported' => $alreadyImported + ($result['nbDownloaded'] ?? 0),
+ 'targetPath' => $targetPath,
+ ]);
+ }
+ if (isset($result['error'])) {
+ $this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]);
+ // Clear the queue and page token so queued sessions are not left stuck after a failure
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ }
+ // On successful completion, atomically transition to the next queued session if any,
+ // so importing_photos never has a transient '0' that would stop the polling client.
+ if (isset($result['finished']) && $result['finished']) {
+ $queueRaw = $this->userConfig->getValueString($userId, Application::APP_ID, 'picker_session_queue', '[]', lazy: true);
+ $queue = json_decode($queueRaw, true);
+ if (!is_array($queue)) {
+ $queue = [];
+ }
+ if (!empty($queue)) {
+ $nextSessionId = array_shift($queue);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_queue', json_encode($queue), lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'picker_session_id', $nextSessionId, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ } else {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
+ }
+ } else {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'importing_photos', '0', lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_imported_photos', 0, lazy: true);
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', 0, lazy: true);
+ }
+ } else {
+ $ts = (new DateTime())->getTimestamp();
+ $this->userConfig->setValueInt($userId, Application::APP_ID, 'last_photo_import_timestamp', $ts, lazy: true);
+ $this->jobList->add(ImportPhotosJob::class, ['user_id' => $userId]);
+ }
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_import_running', '0', lazy: true);
+ }
+
+ /**
+ * Download picked media items from a Picker session into a Nextcloud folder.
+ * Processes up to $maxDownloadSize bytes per call, then returns so the job can re-queue.
+ *
+ * @param string $userId
+ * @param string $sessionId
+ * @param string $targetPath
+ * @param int|null $maxDownloadSize
+ * @param int $alreadyImported
+ * @return array
+ */
+ public function importFromPickerSession(
+ string $userId, string $sessionId, string $targetPath,
+ ?int $maxDownloadSize = null, int $alreadyImported = 0,
+ ): array {
+ if ($sessionId === '') {
+ return ['error' => 'No picker session ID stored'];
+ }
+
+ $userFolder = $this->root->getUserFolder($userId);
+ if (!$userFolder->nodeExists($targetPath)) {
+ $folder = $userFolder->newFolder($targetPath);
+ } else {
+ $folder = $userFolder->get($targetPath);
+ if (!$folder instanceof Folder) {
+ return ['error' => 'Impossible to create Google Photos folder'];
+ }
+ }
+
+ // Page through all picked media items
+ $downloadedSize = 0;
+ $nbDownloaded = 0;
+ $params = ['sessionId' => $sessionId, 'pageSize' => 100];
+ $resumeToken = $this->userConfig->getValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ if ($resumeToken !== '') {
+ $params['pageToken'] = $resumeToken;
+ }
+
+ do {
+ $currentPageToken = $params['pageToken'] ?? '';
+ $result = $this->googleApiService->request(
+ $userId,
+ 'v1/mediaItems',
+ $params,
+ 'GET',
+ self::PICKER_BASE_URL,
+ );
+ if (isset($result['error'])) {
+ return $result;
+ }
+ $items = $result['mediaItems'] ?? [];
+ foreach ($items as $item) {
+ $size = $this->downloadPickerItem($userId, $item, $folder);
+ if ($size !== null) {
+ $nbDownloaded++;
+ $this->userConfig->setValueInt(
+ $userId, Application::APP_ID, 'nb_imported_photos',
+ $alreadyImported + $nbDownloaded, lazy: true,
+ );
+ $downloadedSize += $size;
+ if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) {
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true);
+ return [
+ 'nbDownloaded' => $nbDownloaded,
+ 'targetPath' => $targetPath,
+ 'finished' => false,
+ ];
+ }
+ }
+ }
+ $params['pageToken'] = $result['nextPageToken'] ?? '';
+ } while (isset($result['nextPageToken']));
+
+ $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', '', lazy: true);
+ return [
+ 'nbDownloaded' => $nbDownloaded,
+ 'targetPath' => $targetPath,
+ 'finished' => true,
+ ];
+ }
+
+ /**
+ * Download a single PickedMediaItem into a Nextcloud folder.
+ *
+ * @param string $userId
+ * @param array{id:string, mediaFile?:array, createTime?:string} $item
+ * @param Folder $folder
+ * @return int|null downloaded byte count, or null if skipped/error
+ */
+ private function downloadPickerItem(string $userId, array $item, Folder $folder): ?int {
+ $mediaFile = $item['mediaFile'] ?? null;
+ if ($mediaFile === null) {
+ return null;
+ }
+
+ $baseUrl = $mediaFile['baseUrl'] ?? '';
+ if ($baseUrl === '') {
+ return null;
+ }
+
+ $mimeType = $mediaFile['mimeType'] ?? '';
+ $rawName = $mediaFile['filename'] ?? ($item['id'] ?? 'unknown');
+ $itemId = (string)($item['id'] ?? '');
+ $baseName = $this->fileUtils->sanitizeFilename($rawName, $itemId);
+
+ // Determine the target filename.
+ // Happy path: use the original name. If a file with that name already exists,
+ // check its metadata: same Google ID means this exact photo was already
+ // imported, so skip it. A different ID (name collision with another photo)
+ // falls back to an ID-prefixed filename, which is then the definitive name
+ // for this item across all future sessions.
+ if ($folder->nodeExists($baseName)) {
+ try {
+ $existing = $folder->get($baseName);
+ $meta = $this->metadataManager->getMetadata($existing->getId());
+ if ($meta->hasKey(self::METADATA_KEY) && $meta->getString(self::METADATA_KEY) === $itemId) {
+ return null; // already imported
+ }
+ } catch (\Throwable) {
+ // fall through to ID-prefixed name on any unexpected error
+ }
+ $fileName = $itemId . '_' . $baseName;
+ if ($folder->nodeExists($fileName)) {
+ return null; // ID-prefixed version already imported too
+ }
+ } else {
+ $fileName = $baseName;
+ }
+
+ // Build the download URL: images get =d (full quality + EXIF), videos get =dv
+ $isVideo = str_starts_with($mimeType, 'video/');
+ $downloadUrl = $isVideo ? ($baseUrl . '=dv') : ($baseUrl . '=d');
+
+ $mtime = null;
+ if (isset($item['createTime'])) {
+ try {
+ $mtime = (new DateTime($item['createTime']))->getTimestamp();
+ } catch (Exception $e) {
+ $this->logger->warning('Google Photo, invalid createTime, using current time: ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ }
+ }
+
+ $size = $this->googleApiService->downloadAndSaveFile($userId, $folder, $fileName, $downloadUrl, $mtime);
+ if ($size === null) {
+ return null;
+ }
+
+ // Store the Google media ID in file metadata so future imports can
+ // identify this file by ID rather than filename alone.
+ if ($itemId !== '') {
+ try {
+ $savedFile = $folder->get($fileName);
+ $meta = $this->metadataManager->getMetadata($savedFile->getId(), true);
+ $meta->setString(self::METADATA_KEY, $itemId);
+ $this->metadataManager->saveMetadata($meta);
+ } catch (\Throwable $e) {
+ $this->logger->warning('Google Photo, could not save file metadata: ' . $e->getMessage(), ['app' => Application::APP_ID]);
+ }
+ }
+ return $size;
+ }
+}
diff --git a/lib/Settings/Personal.php b/lib/Settings/Personal.php
index 50c85112..af2867ca 100644
--- a/lib/Settings/Personal.php
+++ b/lib/Settings/Personal.php
@@ -45,6 +45,8 @@ public function getForm(): TemplateResponse {
$userName = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'user_name', lazy: true);
$driveOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'drive_output_dir', '/Google Drive', lazy: true);
$driveOutputDir = $driveOutputDir ?: '/Google Drive';
+ $photoOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'photo_output_dir', '/Google Photos', lazy: true);
+ $photoOutputDir = $photoOutputDir ?: '/Google Photos';
$driveSharedWithMeOutputDir = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'drive_shared_with_me_output_dir', '/Google Drive/Shared with me', lazy: true);
$driveSharedWithMeOutputDir = $driveSharedWithMeOutputDir ?: '/Google Drive/Shared with me';
$considerAllEvents = $this->userConfig->getValueString($this->userId, Application::APP_ID, 'consider_all_events', '1', lazy: true) === '1';
@@ -94,6 +96,7 @@ public function getForm(): TemplateResponse {
'document_format' => $documentFormat,
'drive_output_dir' => $driveOutputDir,
'drive_shared_with_me_output_dir' => $driveSharedWithMeOutputDir,
+ 'photo_output_dir' => $photoOutputDir,
'user_scopes' => $userScopes,
];
$this->initialStateService->provideInitialState('user-config', $userConfig);
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 9bc0d5f7..21a07d29 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -11,6 +11,11 @@
+
+
+
+
+
diff --git a/src/components/AdminSettings.vue b/src/components/AdminSettings.vue
index e35d6977..e5408675 100644
--- a/src/components/AdminSettings.vue
+++ b/src/components/AdminSettings.vue
@@ -25,7 +25,7 @@
{{ t('integration_google', 'Put the "Client ID" and "Client secret" below.') }}
- {{ t('integration_google', 'Finally, go to "APIs & Services" => "Library" and add the following APIs: "Google Drive API", "Google Calendar API", and "People API".') }}
+ {{ t('integration_google', 'Finally, go to "APIs & Services" => "Library" and add the following APIs: "Google Drive API", "Google Calendar API", "People API" and "Google Photos Picker API".') }}
{{ t('integration_google', 'Your Nextcloud users will then see a "Connect to Google" button in their personal settings.') }}
+
+ {{ t('integration_google', 'Up to 2,000 photos can be imported per session. Hold Shift and click to select many photos at once in the Google Photos picker.') }}
+
+
+
+ {{ t('integration_google', 'Warning: Google does not provide location data in imported photos.') }}
+