feat(photos): Add Google Photos import via Picker API#356
feat(photos): Add Google Photos import via Picker API#356AhsanIsEpic wants to merge 2 commits intonextcloud:mainfrom
Conversation
|
This relates to #207 |
|
Undrafted to get review from Copilot, apologies code owners |
There was a problem hiding this comment.
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
GooglePhotosAPIServiceplusImportPhotosJobto 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.
| <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> |
There was a problem hiding this comment.
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.
src/components/PersonalSettings.vue
Outdated
| 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() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
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.
src/components/PersonalSettings.vue
Outdated
| this.pickerSessionId = null | ||
| this.pickerUri = null | ||
| this.pickerSessionDone = false |
There was a problem hiding this comment.
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.
| 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 | |
| }) |
src/components/PersonalSettings.vue
Outdated
| 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.') | ||
| }, |
There was a problem hiding this comment.
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.
| 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.') | |
| }, |
src/components/PersonalSettings.vue
Outdated
| creatingPickerSession: false, | ||
| pickerSessionId: null, | ||
| pickerUri: null, | ||
| pickerSessionDone: false, |
There was a problem hiding this comment.
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.
| pickerSessionDone: false, |
| // 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'])); |
There was a problem hiding this comment.
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.
| $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; |
There was a problem hiding this comment.
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.
| <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> |
There was a problem hiding this comment.
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.
| <version>4.3.2</version> | |
| <version>4.3.1</version> |
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>
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
<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.
| <version>4.3.2</version> | |
| <version>4.3.1</version> |
| {{ 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 }) }} |
There was a problem hiding this comment.
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.
| {{ 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 }) }} |
| this.importingPhotos = response.data.importing_photos | ||
| if (!this.importingPhotos) { | ||
| clearInterval(this.photoImportLoop) | ||
| } else if (launchLoop) { |
There was a problem hiding this comment.
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).
| } else if (launchLoop) { | |
| this.photoImportLoop = null | |
| } else if (launchLoop && !this.photoImportLoop) { |
| if ($alreadyImporting) { | ||
| return ['targetPath' => $targetPath]; | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| if (trim($sessionId) === '') { | |
| return ['error' => 'No picker session ID stored']; | |
| } |
| 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); |
There was a problem hiding this comment.
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.
| $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 [ |
There was a problem hiding this comment.
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.
| $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 [ |
Summary
The Google Photos Library API is deprecated and returns a
403 Forbiddenerror 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.This replaces
photoslibrary.readonly. Existing connected users must disconnect and re-authenticate to grant the new scope. Thecan_access_photosflag 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.xml4.3.1→4.3.2(May need to change back)appinfo/routes.phpFive new routes:
GET/configconfig#getConfigPOST/picker-sessiongoogleAPI#createPickerSessionGET/picker-sessiongoogleAPI#getPickerSessionGET/import-photosgoogleAPI#importPhotosGET/import-photos-infogoogleAPI#getImportPhotosInformationlib/BackgroundJob/ImportPhotosJob.php(new)A
QueuedJobthat delegates toGooglePhotosAPIService::importPhotosJob(). Designed to be re-queued by the service until all picked items are downloaded.lib/Controller/ConfigController.phpPHOTOS_SCOPEconstantINT_CONFIGSextended withnb_imported_photos,last_import_timestamp,photo_import_job_last_startso these are cast to int when readgetConfig()endpoint: returnsuser_nameanduser_scopes— used by the OAuth popup to refresh the parent settings page state without a full reloadoauthRedirect(): now writescan_access_photosto the storeduser_scopesarraysetConfig(): disconnect path now deletesuser_scopes(previously stale flags persisted); cancel-import path now routes throughGooglePhotosAPIService::cancelImport()lib/Controller/GoogleAPIController.phpFour new controller actions:
createPickerSession()—POST /picker-session: callsGooglePhotosAPIService::createPickerSession(), returns sessionid,pickerUri, andpollingConfiggetPickerSession()—GET /picker-session?sessionId=: polls picker session state, returnsmediaItemsSetimportPhotos()—GET /import-photos?sessionId=: callsstartImportPhotos(), returnstargetPathgetImportPhotosInformation()—GET /import-photos-info: returnsimporting_photos,nb_imported_photos,nb_photos_seen,last_import_timestamplib/Notification/Notifier.phpNew
import_photos_finishednotification 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/sessionsonphotospicker.googleapis.com; returns picker URI and polling intervalgetPickerSession()—GET /v1/sessions/{id}; frontend polls untilmediaItemsSet: truedeletePickerSession()—DELETE /v1/sessions/{id}; called on completion or cancellationImport orchestration
startImportPhotos(): validates/creates the output folder, resetsnb_imported_photos,nb_photos_seen, andlast_import_timestampto 0, stores the session ID, enqueuesImportPhotosJobimportPhotosJob(): runs inside user + filesystem scope; includes a timeout guard (checksphoto_import_running+photo_import_job_last_start) to prevent concurrent runs; delegates toimportFromPickerSession(); on finish sends notification + deletes session; on partial run re-queues itselfimportFromPickerSession(): paginatesGET /v1/mediaItems?sessionId=(100/page); per item checks cross-session deduplication, callsdownloadPickerItem(), updatesnb_imported_photosafter each successful download, updatesnb_photos_seenand persistsimported_photo_idsafter each page; honours a 500 MB per-run cap via early return withfinished: falsecancelImport(): removesImportPhotosJobfrom the queue, deletes the active picker session via APIDownload
downloadPickerItem(): builds full-quality URL ({baseUrl}=dfor images,{baseUrl}=dvfor video); creates the file, streams the download, sets mtime fromcreateTime; deletes incomplete files on error; handlesLockedExceptionCross-session deduplication
Downloaded item IDs are stored as a JSON map in the
imported_photo_idsuser 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.phpReads
photo_output_dirfrom user config (defaulting to/Google Photos) and injects it into theuser-configinitial state that the Vue frontend loads.src/components/AdminSettings.vueUpdates the setup instructions to tell admins to enable the Google Photos Picker API in the Google Cloud Console.
src/components/PersonalSettings.vueNew Photos section (previously non-existent in the frontend):
Picker flow
POST /picker-sessionand immediately opens the picker in a popup windowGET /picker-sessionevery ~4 s; whenmediaItemsSetbecomestrue, the import is triggered automatically — no manual confirmation stepImport progress UI
NcLoadingIconspinner throughout"Import queued, starting soon…"whennb_imported_photos === 0(cron not yet run)"{X} of {Y} photos imported"oncenb_photos_seenis availableNcProgressBarshowing completion percentage"You can close this page. You will be notified when the import finishes."Other
/Google Photos)GET /configthen posts the refresheduser_name/user_scopesto the parent window so the settings page updates without a full reloadThis 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:
Feedback and ideas are welcome while this is in draft status.