Skip to content

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

Merged
lukasdotcom merged 35 commits intonextcloud:mainfrom
AhsanIsEpic:main
May 1, 2026
Merged

feat(photos): Add Google Photos import via Picker API#356
lukasdotcom merged 35 commits intonextcloud:mainfrom
AhsanIsEpic:main

Conversation

@AhsanIsEpic
Copy link
Copy Markdown
Contributor

@AhsanIsEpic AhsanIsEpic commented Apr 9, 2026

Closes #357

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.


⚠️ 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/routes.php

Six new routes:

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

lib/BackgroundJob/ImportPhotosJob.php (new)

A QueuedJob that delegates to GooglePhotosAPIService::importPhotosJob(). 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
  • New getConfig() endpoint (GET /config): returns user_name and user_scopes — used by the OAuth popup redirect to refresh the parent settings page state without a full reload
  • oauthRedirect(): writes can_access_photos to the stored user_scopes array
  • setConfig(): disconnect path now deletes user_scopes; cancel-import path routes through GooglePhotosAPIService::cancelImport()

lib/Controller/GoogleAPIController.php

Five new controller actions:

  • createPickerSession()POST /picker-session: creates a Picker session, returns id, pickerUri (with /autoclose appended), and pollingConfig
  • getPickerSession()GET /picker-session?sessionId=: polls session state, returns mediaItemsSet
  • deletePickerSession()DELETE /picker-session?sessionId=: deletes session; only clears the stored picker_session_id config key when it matches the deleted session, so cancelling a UI session cannot corrupt an active import
  • importPhotos()POST /import-photos: calls startImportPhotos(), returns targetPath (and queued: true if an import is already running)
  • getImportPhotosInformation()GET /import-photos-info: returns importing_photos, nb_imported_photos, last_import_timestamp, nb_queued_sessions

lib/Notification/Notifier.php

New import_photos_finished notification: correctly pluralised via $l->n(), links to the import folder in Files, uses the app's dark icon.

lib/Service/GooglePhotosAPIService.php (new)

Core Picker API client and import orchestrator:

Session management

  • createPickerSession()POST /v1/sessions; does not write picker_session_id (only startImportPhotos() does, to avoid overwriting an active import session when the user queues a second picker)
  • getPickerSession()GET /v1/sessions/{id}
  • deletePickerSession()DELETE /v1/sessions/{id}; only clears stored picker_session_id when it matches

Import orchestration

  • startImportPhotos(): validates $sessionId, creates the output folder, stores session ID, resets counters, enqueues ImportPhotosJob. If an import is already running, appends to picker_session_queue and returns queued: true instead
  • importPhotosJob(): runs under user + filesystem scope; concurrent-run guard via photo_import_running + photo_import_job_last_start; delegates to importFromPickerSession(); on finish sends notification + deletes session; on partial run re-queues itself. On successful completion with a non-empty queue, transitions atomically to the next queued session (never writes importing_photos=0 transiently)
  • importFromPickerSession(): paginates GET /v1/mediaItems (100/page); per-item dedup via nodeExists + IFilesMetadataManager metadata (stores Google item ID as integration_google_photo_id); honours a 500 MB per-run cap and persists photo_next_page_token so subsequent job runs resume where they left off
  • cancelImport(): removes the job, deletes the active picker session, clears the queue

Download (downloadPickerItem()GoogleAPIService::downloadAndSaveFile())

  • Full-quality URL (=d for images, =dv for video); mtime set from createTime
  • Delegates file creation, streaming, mtime-setting, and cleanup to GoogleAPIService::downloadAndSaveFile()
  • Stores Google item ID in file metadata for future dedup

lib/Settings/Personal.php

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

src/components/AdminSettings.vue

Updates setup instructions to reference the Google Photos Picker API in the Google Cloud Console.

src/components/PersonalSettings.vue

New Photos section:

lib/Service/GoogleAPIService.php

New downloadAndSaveFile() public method: creates a file in a folder, streams a download via simpleDownload(), sets mtime, and cleans up (deletes the empty file) on all failure paths including LockedException and fopen() returning false. Shared by both GoogleDriveAPIService and GooglePhotosAPIService.

Picker flow

  1. "Open Google Photos picker" creates a session via POST /picker-session and opens it in a popup (noopener,noreferrer)
  2. The frontend polls GET /picker-session every ~4 s; when mediaItemsSet becomes true, the import is triggered automatically
  3. While waiting: shift-click hint, location-data warning, "import starts automatically" hint, reopen button, and "Cancel photo picking" button

Import progress UI

  • NcLoadingIcon spinner
  • "Import queued, starting soon…" when nb_imported_photos === 0 and cron hasn't run yet
  • "{X} photos imported" once the job starts
  • Queued session count hint when nb_queued_sessions > 0
  • "You can close this page…" message
  • "Queue another session" button to open a second picker while an import is running
  • "Cancel importing all photos" button

Other

  • Configurable import directory (pencil button, defaults to /Google Photos)
  • Progress polling every 5 s
  • OAuth popup uses BroadcastChannel('integration_google_oauth') exclusively for the success handshake; popupSuccess.js posts to the channel and closes. All popup windows use noopener,noreferrer

src/popupSuccess.js

Uses BroadcastChannel directly (no opener fallback): creates the channel, posts { username }, closes the channel, then closes the window unconditionally.


⚠️ Draft Status

Even though this implementation currently works. It was developed with AI assistance (GitHub Copilot / Claude Sonnet) and should be treated as draft quality. It works end-to-end in a local Docker environment but has not been through a thorough human review or extensive testing.

Feedback welcome on anything including:

  • Correctness, security, or Nextcloud coding-standards issues
  • UX flow
  • PHP quality

@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 thread src/components/AdminSettings.vue
Comment thread src/components/PersonalSettings.vue
Comment thread src/components/PersonalSettings.vue Outdated
Comment thread src/components/PersonalSettings.vue Outdated
Comment thread src/components/PersonalSettings.vue Outdated
Comment thread lib/Service/GooglePhotosAPIService.php
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread lib/Controller/ConfigController.php Outdated
Comment thread appinfo/info.xml Outdated
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.

Comment thread appinfo/info.xml Outdated
Comment thread src/components/PersonalSettings.vue Outdated
Comment thread src/components/PersonalSettings.vue Outdated
Comment thread lib/Service/GooglePhotosAPIService.php
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
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 9 out of 10 changed files in this pull request and generated 3 comments.


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

Comment thread src/components/PersonalSettings.vue
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
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 9 out of 10 changed files in this pull request and generated 3 comments.


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

Comment thread src/components/PersonalSettings.vue
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
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 4 comments.


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

Comment thread lib/Service/GooglePhotosAPIService.php
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread appinfo/routes.php Outdated
Comment thread src/popupSuccess.js Outdated
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 2 comments.


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

Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread src/components/PersonalSettings.vue
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 11 out of 12 changed files in this pull request and generated 3 comments.


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

Comment thread src/components/PersonalSettings.vue Outdated
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
Comment thread lib/Service/GooglePhotosAPIService.php Outdated
AhsanIsEpic and others added 16 commits April 30, 2026 04:47
- popupSuccess.js: wrap BroadcastChannel send in try/finally so
  window.close() always runs and feature-detect BroadcastChannel
- GooglePhotosAPIService.php: reset photo_import_running and
  photo_import_job_last_start before early return when importing_photos=0
- GooglePhotosAPIService.php: use is_array() guard when appending to
  json-decoded picker_session_queue in startImportPhotos
- GoogleAPIController.php: use is_array() guard before count() on
  json-decoded picker_session_queue
- PersonalSettings.vue: store BroadcastChannel on component as
  oauthBroadcastChannel, close previous instance before creating a new
  one, and close in beforeUnmount to prevent channel leaks

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
On the error path in importPhotosJob(), picker_session_queue and
photo_next_page_token were not cleared. Any queued sessions would remain
stuck in config and never be processed. Now both are reset on error so
the UI is consistent and no phantom queued sessions are left behind.

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
- Update copyright headers in new PHP files to
  'Nextcloud GmbH and Nextcloud contributors 2025'
  with @author Ahsan Ahmed
- Revert package-lock.json engine version bumps
  (these are auto-managed in separate PRs)
- Refactor file download logic into GoogleAPIService::downloadAndSaveFile()
  so it can be reused by both GooglePhotosAPIService and GoogleDriveAPIService;
  remove the now-redundant private downloadAndSaveFile from GoogleDriveAPIService
- Add ImportPhotosJob MissingOverrideAttribute to psalm-baseline.xml
- Fix InvalidReturnType/InvalidReturnStatement in GooglePhotosAPIService
  via updated return-type docblocks and inline psalm-suppress annotations

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
- Clear driveImportLoop in beforeUnmount() to prevent Drive polling
  timer leak when component is destroyed (nextcloud#92)
- Move pickerPollTimer clearance into onImportPhotos() success handler;
  restart polling on import request failure so user is not stuck with
  an active session and no way to trigger import (nextcloud#93)
- Delete newly created file in downloadAndSaveFile() when fopen throws
  LockedException or returns false, preventing zero-byte placeholder
  files from being left behind on early failure paths (nextcloud#94)

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Mirror the same defensive pattern used in startImportPhotos(): if the
stored picker_session_queue value cannot be decoded to an array (e.g.
config corruption), fall back to an empty array rather than passing a
non-array value to array_shift().

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
- Cancel active Drive and Photos imports on user disconnect so
  background jobs don't re-queue against deleted tokens (nextcloud#106)
- Add startingPhotoImport guard in pollPickerSession() to prevent
  concurrent /import-photos POST requests across poll ticks (nextcloud#107)

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
- Validate pollInterval from pollingConfig with Number.isFinite() and
  fall back to 5000ms to prevent NaN-driven 0ms poll loop (nextcloud#110)
- Reset importing_drive/photo and *_import_running flags on disconnect
  so re-queued jobs exit cleanly against deleted tokens (nextcloud#111)
- Null pickerPollTimer after clearInterval in onCancelPickerSession()
  to keep polling-restart guards correct (nextcloud#112)

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
- Fix php-cs: remove duplicate /** in GoogleDriveAPIService docblock
- Remove redundant axios config fetch from handleOAuthMessage; loadData()
  already refreshes page state after OAuth (thread 62)
- Rename last_import_timestamp -> last_photo_import_timestamp for
  consistency with last_drive_import_timestamp (thread 63)
- Move importing_*/import_running flag resets into cancelImport() for
  both services; remove the duplicate setValueString calls from the
  disconnect path in ConfigController (thread 64)
- cancelImport() now also deletes all queued picker sessions from
  picker_session_queue so no stale sessions remain in Google (thread 65)
- Consolidate duplicate deletePickerSession() calls (success + error)
  into a single call at the top of the terminal-state block (thread 66)

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
…File()

Prevents exceptions thrown during file cleanup (on LockedException,
fopen()=false, or simpleDownload error) from propagating out of the
helper and aborting the import job. The method now consistently
returns null on any failure path.

Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Co-authored-by: Copilot <copilot@github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
@AhsanIsEpic
Copy link
Copy Markdown
Contributor Author

AhsanIsEpic commented Apr 30, 2026

Apologies for the entire history looking like its been recommitted, I forgot a sign-off in one of my commits and didn't realise that rebasing my sign-offs would make GitHub show my commits as if they were all newly pushed together

Copy link
Copy Markdown
Member

@lukasdotcom lukasdotcom left a comment

Choose a reason for hiding this comment

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

No problem on the commit history I can tell which commits are actually new. I missed these two small things, but once that is fixed I can merge this.

Comment thread src/components/PersonalSettings.vue Outdated
Comment thread src/components/PersonalSettings.vue Outdated
AhsanIsEpic and others added 2 commits May 1, 2026 12:27
Co-authored-by: Lukas Schaefer <lukas@lschaefer.xyz>
Signed-off-by: Ahsan <61637519+AhsanIsEpic@users.noreply.github.com>
Signed-off-by: Ahsan Ahmed <61637519+AhsanIsEpic@users.noreply.github.com>
@AhsanIsEpic AhsanIsEpic requested a review from lukasdotcom May 1, 2026 14:27
@AhsanIsEpic
Copy link
Copy Markdown
Contributor Author

I think this is ready now to merge

@lukasdotcom lukasdotcom merged commit e3e62ad into nextcloud:main May 1, 2026
21 checks passed
@lukasdotcom
Copy link
Copy Markdown
Member

Thanks @AhsanIsEpic for the contribution it is now merged and will be in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Replace deprecated Google Photos Library API with Picker API

3 participants