Skip to content

feat(photos): Add Google Photos import via Picker API#356

Draft
AhsanIsEpic wants to merge 2 commits intonextcloud:mainfrom
AhsanIsEpic:main
Draft

feat(photos): Add Google Photos import via Picker API#356
AhsanIsEpic wants to merge 2 commits intonextcloud:mainfrom
AhsanIsEpic:main

Conversation

@AhsanIsEpic
Copy link
Copy Markdown
Contributor

@AhsanIsEpic AhsanIsEpic commented Apr 9, 2026

Summary

The Google Photos Library API is deprecated and returns a 403 Forbidden error for new OAuth clients. This PR replaces it entirely with the Google Photos Picker API, which requires users to explicitly select photos in a Google-hosted popup before Nextcloud can access any data. This is the only viable path forward.


⚠️ New OAuth Scope Required

https://www.googleapis.com/auth/photospicker.mediaitems.readonly

This replaces photoslibrary.readonly. Existing connected users must disconnect and re-authenticate to grant the new scope. The can_access_photos flag is written to user config during the OAuth redirect and controls visibility of the Photos section in personal settings.

Admin setup: In the Google Cloud Console, enable the "Google Photos Picker API" (not the deprecated Photos Library API).


Changed Files

appinfo/info.xml

  • Version bumped 4.3.14.3.2 (May need to change back)

appinfo/routes.php

Five new routes:

Verb URL Handler
GET /config config#getConfig
POST /picker-session googleAPI#createPickerSession
GET /picker-session googleAPI#getPickerSession
GET /import-photos googleAPI#importPhotos
GET /import-photos-info googleAPI#getImportPhotosInformation

lib/BackgroundJob/ImportPhotosJob.php (new)

A QueuedJob that delegates to GooglePhotosAPIService::importPhotosJob(). Designed to be re-queued by the service until all picked items are downloaded.

lib/Controller/ConfigController.php

  • New PHOTOS_SCOPE constant
  • INT_CONFIGS extended with nb_imported_photos, last_import_timestamp, photo_import_job_last_start so these are cast to int when read
  • getConfig() endpoint: returns user_name and user_scopes — used by the OAuth popup to refresh the parent settings page state without a full reload
  • oauthRedirect(): now writes can_access_photos to the stored user_scopes array
  • setConfig(): disconnect path now deletes user_scopes (previously stale flags persisted); cancel-import path now routes through GooglePhotosAPIService::cancelImport()

lib/Controller/GoogleAPIController.php

Four new controller actions:

  • createPickerSession()POST /picker-session: calls GooglePhotosAPIService::createPickerSession(), returns session id, pickerUri, and pollingConfig
  • getPickerSession()GET /picker-session?sessionId=: polls picker session state, returns mediaItemsSet
  • importPhotos()GET /import-photos?sessionId=: calls startImportPhotos(), returns targetPath
  • getImportPhotosInformation()GET /import-photos-info: returns importing_photos, nb_imported_photos, nb_photos_seen, last_import_timestamp

lib/Notification/Notifier.php

New import_photos_finished notification case: pluralised %n photo(s) were imported from Google., links to the import folder in Files, uses the app's dark icon.

lib/Service/GooglePhotosAPIService.php (new, ~420 lines)

Core Picker API client and import orchestrator:

Session management

  • createPickerSession()POST /v1/sessions on photospicker.googleapis.com; returns picker URI and polling interval
  • getPickerSession()GET /v1/sessions/{id}; frontend polls until mediaItemsSet: true
  • deletePickerSession()DELETE /v1/sessions/{id}; called on completion or cancellation

Import orchestration

  • startImportPhotos(): validates/creates the output folder, resets nb_imported_photos, nb_photos_seen, and last_import_timestamp to 0, stores the session ID, enqueues ImportPhotosJob
  • importPhotosJob(): runs inside user + filesystem scope; includes a timeout guard (checks photo_import_running + photo_import_job_last_start) to prevent concurrent runs; delegates to importFromPickerSession(); on finish sends notification + deletes session; on partial run re-queues itself
  • importFromPickerSession(): paginates GET /v1/mediaItems?sessionId= (100/page); per item checks cross-session deduplication, calls downloadPickerItem(), updates nb_imported_photos after each successful download, updates nb_photos_seen and persists imported_photo_ids after each page; honours a 500 MB per-run cap via early return with finished: false
  • cancelImport(): removes ImportPhotosJob from the queue, deletes the active picker session via API

Download

  • downloadPickerItem(): builds full-quality URL ({baseUrl}=d for images, {baseUrl}=dv for video); creates the file, streams the download, sets mtime from createTime; deletes incomplete files on error; handles LockedException

Cross-session deduplication
Downloaded item IDs are stored as a JSON map in the imported_photo_ids user config key. The map is scoped to the target folder path (photo_dedup_target_path): if the user changes the import folder, the map is reset so photos can be downloaded fresh into the new location.

lib/Settings/Personal.php

Reads photo_output_dir from user config (defaulting to /Google Photos) and injects it into the user-config initial state that the Vue frontend loads.

src/components/AdminSettings.vue

Updates the setup instructions to tell admins to enable the Google Photos Picker API in the Google Cloud Console.

src/components/PersonalSettings.vue

New Photos section (previously non-existent in the frontend):

Picker flow

  1. "Open Google Photos picker" creates a session via POST /picker-session and immediately opens the picker in a popup window
  2. The frontend polls GET /picker-session every ~4 s; when mediaItemsSet becomes true, the import is triggered automatically — no manual confirmation step
  3. While the popup is open: shift-click multi-select hint, location data warning (⚠), "import starts automatically" hint, an "Open" button (also reopens the popup if closed), and a "Cancel" button below the waiting hint

Import progress UI

  • NcLoadingIcon spinner throughout
  • "Import queued, starting soon…" when nb_imported_photos === 0 (cron not yet run)
  • "{X} of {Y} photos imported" once nb_photos_seen is available
  • NcProgressBar showing completion percentage
  • "You can close this page. You will be notified when the import finishes."
  • Cancel button

Other

  • Configurable import directory (editable via pencil button, defaults to /Google Photos)
  • Progress polling every 5 s (the existing Drive polling used 10 s)
  • OAuth popup path: on redirect, the popup calls GET /config then posts the refreshed user_name/user_scopes to the parent window so the settings page updates without a full reload

⚠️ Draft Status

This implementation was developed with AI assistance (GitHub Copilot / Claude Sonnet) and should be considered "AI slop" at this stage. The feature works end-to-end in a local Docker environment, but the code has not been through a proper human review pass and has not been extensively tested. Expect a lot of changes from myself for a while.

The purpose of this draft PR is to get input on everything including:

  • Any obvious correctness, security, or Nextcloud coding standards issues
  • Whether the UX flow makes sense
  • My ability to code in PHP

Feedback and ideas are welcome while this is in draft status.

@AhsanIsEpic AhsanIsEpic changed the title feat(photos): replace legacy Photos API with Google Photos Picker API feat(photos): Add Google Photos import via Picker API Apr 9, 2026
@AhsanIsEpic
Copy link
Copy Markdown
Contributor Author

This relates to #207

@AhsanIsEpic AhsanIsEpic marked this pull request as ready for review April 9, 2026 02:39
Copilot AI review requested due to automatic review settings April 9, 2026 02:39
@AhsanIsEpic
Copy link
Copy Markdown
Contributor Author

AhsanIsEpic commented Apr 9, 2026

Undrafted to get review from Copilot, apologies code owners

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Replaces the deprecated Google Photos Library API integration with the Google Photos Picker API flow, adding a user-driven photo selection popup and a background-job-based import into Nextcloud Files.

Changes:

  • Added a Photos section in personal settings with Picker-session creation/polling and import progress UI.
  • Introduced a new GooglePhotosAPIService plus ImportPhotosJob to orchestrate downloads from Picker sessions.
  • Added new routes/controllers/config plumbing and a completion notification.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
src/components/PersonalSettings.vue Adds Photos picker/import UI, polling loops, and OAuth popup state refresh.
src/components/AdminSettings.vue Updates admin setup instructions for required Google APIs.
lib/Service/GooglePhotosAPIService.php Implements Picker session management and background import/download orchestration.
lib/BackgroundJob/ImportPhotosJob.php Queued job wrapper that delegates to the Photos import service.
lib/Controller/GoogleAPIController.php Adds endpoints for picker sessions, import start, and import progress info.
lib/Controller/ConfigController.php Adds Photos scope, GET /config endpoint, and cancel-import wiring.
lib/Notification/Notifier.php Adds import_photos_finished notification linking to the import directory.
lib/Settings/Personal.php Adds photo_output_dir initial state defaulting to /Google Photos.
appinfo/routes.php Registers the new config/picker/import routes.
appinfo/info.xml Bumps app version to 4.3.2.
package-lock.json Updates engines metadata (Node/NPM) to match package.json.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 25 to 31
<p class="settings-hint">
{{ t('integration_google', 'Put the "Client ID" and "Client secret" below.') }}
<br>
{{ 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 "Photos Library API".') }}
<br>
{{ t('integration_google', 'Your Nextcloud users will then see a "Connect to Google" button in their personal settings.') }}
</p>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admin setup text still instructs enabling "Photos Library API", but this PR migrates to the Google Photos Picker API (photospicker.googleapis.com). Update the hint to reference "Google Photos Picker API" instead, otherwise admins will enable the wrong API and the feature will fail.

Copilot uses AI. Check for mistakes.
Comment on lines 551 to 570
window.addEventListener('message', (event) => {
if (!event.data?.username) {
return
}
console.debug('Child window message received', event)
this.state.user_name = event.data.username
this.loadData()
// Fetch the full config (including user_scopes) so the page
// updates without requiring a manual refresh
const configUrl = generateUrl('/apps/integration_google/config')
axios.get(configUrl)
.then((response) => {
if (response.data) {
Object.assign(this.state, response.data)
}
this.loadData()
})
.catch(() => {
this.loadData()
})
})
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OAuth popup postMessage handler accepts any message containing a username and does not validate event.origin (or event.source). This allows any page to spoof a login success message and mutate UI state. Restrict messages to the expected same-origin (e.g., window.location.origin) and remove the listener after handling the first valid message.

Copilot uses AI. Check for mistakes.
Comment on lines +852 to +854
this.pickerSessionId = null
this.pickerUri = null
this.pickerSessionDone = false
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onCancelPickerSession() only clears local component state; it does not delete the Picker session on the backend nor clear the persisted picker_session_id user config (set during createPickerSession). Add a backend call to delete/cancel the session (and clear stored session id) so cancelled sessions don't linger and can't be accidentally reused by a background job.

Suggested change
this.pickerSessionId = null
this.pickerUri = null
this.pickerSessionDone = false
const sessionId = this.pickerSessionId
const requests = []
if (sessionId) {
const pickerSessionUrl = generateUrl('/apps/integration_google/picker-session')
requests.push(
axios.delete(pickerSessionUrl, { params: { sessionId } }),
)
}
const configUrl = generateUrl('/apps/integration_google/config')
requests.push(
axios.put(configUrl, {
values: {
picker_session_id: '',
},
}),
)
Promise.allSettled(requests)
.then((results) => {
results.forEach((result) => {
if (result.status === 'rejected') {
console.debug('Picker session cancel error', result.reason)
}
})
})
.then(() => {
this.pickerSessionId = null
this.pickerUri = null
this.pickerSessionDone = false
})

Copilot uses AI. Check for mistakes.
Comment on lines +414 to +419
lastPhotoImportDate() {
return this.lastPhotoImportTimestamp !== 0
? t('integration_google', 'Last Google Photos import job at {date}', { date: moment.unix(this.lastPhotoImportTimestamp).format('LLL') })
: t('integration_google', 'Google Photos background import process will begin soon.') + ' '
+ t('integration_google', 'You can close this page. You will be notified when it finishes.')
},
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastPhotoImportDate computed property is introduced but never used in the template or methods. Either wire it into the UI (e.g., show last job run time) or remove it to avoid dead code.

Suggested change
lastPhotoImportDate() {
return this.lastPhotoImportTimestamp !== 0
? t('integration_google', 'Last Google Photos import job at {date}', { date: moment.unix(this.lastPhotoImportTimestamp).format('LLL') })
: t('integration_google', 'Google Photos background import process will begin soon.') + ' '
+ t('integration_google', 'You can close this page. You will be notified when it finishes.')
},

Copilot uses AI. Check for mistakes.
creatingPickerSession: false,
pickerSessionId: null,
pickerUri: null,
pickerSessionDone: false,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pickerSessionDone is added to component state but is never set to true or referenced anywhere. Remove it or implement the intended state transition to avoid confusing/unused state.

Suggested change
pickerSessionDone: false,

Copilot uses AI. Check for mistakes.
Comment on lines +276 to +328
// Page through all picked media items
$downloadedSize = 0;
$nbDownloaded = 0;
$totalSeenNumber = 0;
$params = ['sessionId' => $sessionId, 'pageSize' => 100];

do {
$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) {
$totalSeenNumber++;
$itemId = $item['id'] ?? '';
// Skip photos already imported in a previous session
if ($itemId !== '' && array_key_exists($itemId, $importedIds)) {
continue;
}
$size = $this->downloadPickerItem($userId, $item, $folder);
if ($size !== null) {
$nbDownloaded++;
if ($itemId !== '') {
$importedIds[$itemId] = 1;
}
$this->userConfig->setValueInt(
$userId, Application::APP_ID, 'nb_imported_photos',
$alreadyImported + $nbDownloaded, lazy: true,
);
$downloadedSize += $size;
if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) {
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true);
return [
'nbDownloaded' => $nbDownloaded,
'targetPath' => $targetPath,
'finished' => false,
'totalSeen' => $totalSeenNumber,
];
}
}
}
// Update progress counters after each page
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true);
$params['pageToken'] = $result['nextPageToken'] ?? '';
} while (isset($result['nextPageToken']));
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The background job re-queues itself based on a 500MB cap, but importFromPickerSession() always paginates from the beginning and has no persisted cursor (nextPageToken) across job runs. For large selections this becomes increasingly expensive (re-fetching and iterating already-seen items every run) and makes nb_photos_seen/progress misleading. Persist the current pageToken (and ideally totalSeen) in user config so the next job resumes where it left off.

Copilot uses AI. Check for mistakes.
Comment on lines +373 to +405
$savedFile = $folder->newFile($fileName);
try {
$resource = $savedFile->fopen('w');
} catch (LockedException $e) {
$this->logger->warning('Google Photo, error opening target file: file is locked', ['app' => Application::APP_ID]);
return null;
}
if ($resource === false) {
$this->logger->warning('Google Photo, error opening target file', ['app' => Application::APP_ID]);
return null;
}

$res = $this->googleApiService->simpleDownload($userId, $downloadUrl, $resource);
if (!isset($res['error'])) {
if (is_resource($resource)) {
fclose($resource);
}
if (isset($item['createTime'])) {
$d = new DateTime($item['createTime']);
$savedFile->touch($d->getTimestamp());
} else {
$savedFile->touch();
}
$stat = $savedFile->stat();
return (int)($stat['size'] ?? 0);
} else {
$this->logger->warning('Google API error downloading photo: ' . $res['error'], ['app' => Application::APP_ID]);
if ($savedFile->isDeletable()) {
$savedFile->unlock(ILockingProvider::LOCK_EXCLUSIVE);
$savedFile->delete();
}
}
return null;
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadPickerItem() creates the target file before opening/writing, but on LockedException or $resource === false it returns without deleting the newly created empty file. Also, when simpleDownload() returns an error the file handle is never closed before deletion. Mirror the Drive download helper: catch NotPermittedException on newFile(), and ensure the resource is closed and incomplete file is deleted on all failure paths.

Copilot uses AI. Check for mistakes.
<summary>Import Google data into Nextcloud</summary>
<description><![CDATA[Google integration allows you to automatically migrate your Google calendars, contacts, and files into Nextcloud.]]></description>
<version>4.3.1</version>
<version>4.3.2</version>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

appinfo/info.xml version is bumped to 4.3.2, but package.json/package-lock.json still declare version 4.3.1. If the project expects these to stay in sync (they currently were), update the JS package versions as well or revert the app version bump until release is decided.

Suggested change
<version>4.3.2</version>
<version>4.3.1</version>

Copilot uses AI. Check for mistakes.
Replace the deprecated Google Photos Library API (which required the broad
`photoslibrary.readonly` scope) with the new Google Photos Picker API. Users
now explicitly select photos in a first-party Google-hosted popup before any
data is accessed by Nextcloud.

## New OAuth scope
Adds `https://www.googleapis.com/auth/photospicker.mediaitems.readonly` in
place of the old library read scope. Existing users must disconnect and
re-authenticate to obtain this scope.

## Backend (PHP)

### GooglePhotosAPIService (new)
- POST /v1/sessions — create a Picker session and return the hosted picker URI
- GET  /v1/sessions/{id} — poll until mediaItemsSet becomes true
- GET  /v1/mediaItems?sessionId= — paginate picked items (100 per page)
- DELETE /v1/sessions/{id} — clean up session after import or cancellation
- importFromPickerSession(): paginated download loop capped at 500 MB per run;
  persists nextPageToken in user config so large libraries resume across job
  runs without re-fetching already-processed pages; updates nb_imported_photos
  and nb_photos_seen after each item/page for live frontend progress
- Cross-session deduplication: downloaded item IDs stored in imported_photo_ids
  user config key and skipped on re-import; ID list is scoped to target folder
- downloadPickerItem(): full-quality download URL (=d images, =dv video);
  deletes orphaned empty file on LockedException or fopen failure; closes
  resource before unlocking/deleting on download error
- startImportPhotos(): resets all progress counters including photo_next_page_token
- cancelImport(): removes pending job and deletes active picker session via API
- deletePickerSession(): DELETE /v1/sessions/{id} + clears picker_session_id

### ImportPhotosJob (new background job)
- Extends QueuedJob; runs inside user and filesystem scope
- Timeout guard to prevent overlapping runs
- Downloads up to 500 MB per run then re-queues; sends Nextcloud notification
  on completion, then cleans up the picker session

### GoogleAPIController (extended)
- createPickerSession — POST /picker-session
- getPickerSession    — GET  /picker-session?sessionId=
- deletePickerSession — DELETE /picker-session?sessionId=
- importPhotos        — GET  /import-photos?sessionId=
- getImportPhotosInformation — GET /import-photos-info

### ConfigController (extended)
- GET /config returns current user config; used by OAuth popup to refresh
  parent page without full reload
- setConfig clears user_scopes on disconnect so stale scope flags do not persist

### Notifier (extended)
- import_photos_finished: pluralised notification with item count and target path

### Routes
- POST/GET/DELETE /picker-session
- GET /import-photos, /import-photos-info

## Frontend (Vue)

### AdminSettings.vue
- Updated API list to show "Google Photos Picker API" (not "Photos Library API")

### PersonalSettings.vue
Picker flow (replaces old library-read UI entirely):
1. "Open Google Photos picker" creates a session and immediately opens the
   picker in a popup — no second confirmation step
2. setInterval polls GET /picker-session every ~4 s; when mediaItemsSet is
   true the import is triggered automatically
3. While picker is open: hint about 2000-photo limit, location data warning,
   "auto-import" hint, Open Picker and Cancel buttons
4. Import-in-progress UI: spinner, queued/progress messages, progress bar,
   cancel button; progress polled every 5 s
- postMessage origin validated against window.location.origin to prevent
  spoofed messages from cross-origin iframes/popups; listener removed after
  first valid message to avoid leaks
- onCancelPickerSession calls DELETE /picker-session backend before clearing
  local state so the session is cleaned up on Google's side
- Removed unused pickerSessionDone data property and lastPhotoImportDate
  computed property

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
@AhsanIsEpic AhsanIsEpic requested a review from Copilot April 9, 2026 03:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

<summary>Import Google data into Nextcloud</summary>
<description><![CDATA[Google integration allows you to automatically migrate your Google calendars, contacts, and files into Nextcloud.]]></description>
<version>4.3.1</version>
<version>4.3.2</version>
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<version> is bumped to 4.3.2, but package.json still declares version 4.3.1. If the project expects these versions to stay in sync, either revert this bump or update the JS package version to match.

Suggested change
<version>4.3.2</version>
<version>4.3.1</version>

Copilot uses AI. Check for mistakes.
{{ t('integration_google', 'Import queued, starting soon…') }}
</span>
<span v-else-if="nbPhotosSeen > 0">
{{ n('integration_google', '{imported} of {total} photo imported', '{imported} of {total} photos imported', nbImportedPhotos, { imported: nbImportedPhotos, total: nbPhotosSeen }) }}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pluralization is based on nbImportedPhotos, which produces grammatically incorrect output when imported === 1 but total > 1 (e.g. "1 of 2 photo imported"). Consider using a non-pluralized string like "{imported} of {total} photos imported" or pluralizing based on total.

Suggested change
{{ n('integration_google', '{imported} of {total} photo imported', '{imported} of {total} photos imported', nbImportedPhotos, { imported: nbImportedPhotos, total: nbPhotosSeen }) }}
{{ t('integration_google', '{imported} of {total} photos imported', { imported: nbImportedPhotos, total: nbPhotosSeen }) }}

Copilot uses AI. Check for mistakes.
this.importingPhotos = response.data.importing_photos
if (!this.importingPhotos) {
clearInterval(this.photoImportLoop)
} else if (launchLoop) {
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPhotoImportValues(true) can create multiple polling intervals if called more than once while importing_photos is true (e.g. loadData() can be triggered multiple times). Mirror the Drive logic by only starting the interval if photoImportLoop is not already set (or clear any existing interval before creating a new one).

Suggested change
} else if (launchLoop) {
this.photoImportLoop = null
} else if (launchLoop && !this.photoImportLoop) {

Copilot uses AI. Check for mistakes.
if ($alreadyImporting) {
return ['targetPath' => $targetPath];
}

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startImportPhotos() starts an import even when $sessionId is empty, enqueueing a job that will immediately fail ("No picker session ID stored"). Validate $sessionId and return an error response before setting importing_photos / enqueuing the job.

Suggested change
if (trim($sessionId) === '') {
return ['error' => 'No picker session ID stored'];
}

Copilot uses AI. Check for mistakes.
Comment on lines +209 to +224
if (isset($result['error']) || (isset($result['finished']) && $result['finished'])) {
if (isset($result['finished']) && $result['finished']) {
$this->googleApiService->sendNCNotification($userId, 'import_photos_finished', [
'nbImported' => $alreadyImported + ($result['nbDownloaded'] ?? 0),
'targetPath' => $targetPath,
]);
// Clean up the picker session now that we have all items
if ($sessionId !== '') {
$this->deletePickerSession($userId, $sessionId);
}
}
if (isset($result['error'])) {
$this->logger->error('Google Photo import error: ' . $result['error'], ['app' => Application::APP_ID]);
}
$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);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On job failure ($result['error']), the code stops importing but does not clean up the active Picker session (remote session + stored picker_session_id). Consider deleting the picker session / clearing picker_session_id in the error path as well to avoid leaving stale sessions behind.

Copilot uses AI. Check for mistakes.
$downloadedSize += $size;
if ($maxDownloadSize !== null && $downloadedSize > $maxDownloadSize) {
$this->userConfig->setValueInt($userId, Application::APP_ID, 'nb_photos_seen', $totalSeenNumber, lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple statements collapsed onto a single line, which hurts readability and is likely to fail coding-standard checks (PSR-12/php-cs-fixer). Split these config writes and the return onto separate properly-indented lines.

Suggested change
$this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true); $this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true); return [
$this->userConfig->setValueString($userId, Application::APP_ID, 'imported_photo_ids', json_encode($importedIds), lazy: true);
$this->userConfig->setValueString($userId, Application::APP_ID, 'photo_next_page_token', $currentPageToken, lazy: true);
return [

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants