-
Notifications
You must be signed in to change notification settings - Fork 18
[PB-6085] feature/Added photo backup upload service layer #436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/PB-6068-photos-backup
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,43 @@ | ||
| import * as MediaLibrary from 'expo-media-library'; | ||
| import { logger } from 'src/services/common'; | ||
|
|
||
| const PAGE_SIZE = 200; | ||
| const MEDIA_TYPES = [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video]; | ||
|
|
||
| const paginateAssets = async ( | ||
| options: Omit<MediaLibrary.AssetsOptions, 'first' | 'after'>, | ||
| onAssetFetched: (asset: MediaLibrary.Asset) => boolean, | ||
| ): Promise<void> => { | ||
| let cursor: string | undefined; | ||
| do { | ||
| const page = await MediaLibrary.getAssetsAsync({ first: PAGE_SIZE, after: cursor, ...options }); | ||
| for (const asset of page.assets) { | ||
| if (onAssetFetched(asset)) return; | ||
| } | ||
| cursor = page.hasNextPage ? page.endCursor : undefined; | ||
| } while (cursor); | ||
| }; | ||
|
|
||
| export const PhotoAssetScanner = { | ||
| async getAssetsByIds(assetIds: string[]): Promise<MediaLibrary.Asset[]> { | ||
| const pendingIdSet = new Set(assetIds); | ||
| const resolvedAssets: MediaLibrary.Asset[] = []; | ||
|
|
||
| await paginateAssets({ mediaType: MEDIA_TYPES }, (asset) => { | ||
| if (pendingIdSet.has(asset.id)) resolvedAssets.push(asset); | ||
| return resolvedAssets.length === assetIds.length; | ||
| }); | ||
|
|
||
| return resolvedAssets; | ||
| }, | ||
|
|
||
| async scanAll(): Promise<MediaLibrary.Asset[]> { | ||
| const results: MediaLibrary.Asset[] = []; | ||
| let cursor: string | undefined; | ||
|
|
||
| do { | ||
| const batch = await MediaLibrary.getAssetsAsync({ | ||
| first: 200, | ||
| after: cursor, | ||
| mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video], | ||
| sortBy: MediaLibrary.SortBy.modificationTime, | ||
| }); | ||
| results.push(...batch.assets); | ||
| cursor = batch.hasNextPage ? batch.endCursor : undefined; | ||
| } while (cursor); | ||
|
|
||
| await paginateAssets({ mediaType: MEDIA_TYPES, sortBy: MediaLibrary.SortBy.modificationTime }, (asset) => { | ||
| results.push(asset); | ||
| return false; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So that it never stops fetching assets, as we want to fetch them all. In any case, as I mentioned in my previous comment, perhaps the best approach is to refactor the common funciton so that the only thing the functions |
||
| }); | ||
|
|
||
| logger.info(`[PhotoAssetScanner] Scan complete — ${results.length} assets`); | ||
| return results; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import asyncStorageService from 'src/services/AsyncStorageService'; | ||
| import { logger } from 'src/services/common'; | ||
| import { createFolderWithMerge } from 'src/services/drive/folder/folderOrchestration.service'; | ||
|
|
||
| const PHOTOS_BACKUP_ROOT_NAME = 'Photos Backup'; | ||
|
|
||
| class PhotoBackupFolderService { | ||
| private photosRootUuid: string | null = null; | ||
| private deviceFolderUuid = new Map<string, string>(); | ||
|
Check warning on line 9 in src/services/photos/PhotoBackupFolders.ts
|
||
| private yearFolderUuid = new Map<string, string>(); | ||
|
Check warning on line 10 in src/services/photos/PhotoBackupFolders.ts
|
||
| private monthFolderUuid = new Map<string, string>(); | ||
|
Check warning on line 11 in src/services/photos/PhotoBackupFolders.ts
|
||
| private dayFolderUuid = new Map<string, string>(); | ||
|
Check warning on line 12 in src/services/photos/PhotoBackupFolders.ts
|
||
|
|
||
| async getOrCreateFolderForDate(deviceId: string, date: Date): Promise<string> { | ||
| const year = date.getFullYear().toString(); | ||
| const month = String(date.getMonth() + 1).padStart(2, '0'); | ||
| const day = String(date.getDate()).padStart(2, '0'); | ||
|
|
||
| const dayKey = `${deviceId}/${year}/${month}/${day}`; | ||
| const localDayFolderUuid = this.dayFolderUuid.get(dayKey); | ||
| if (localDayFolderUuid) return localDayFolderUuid; | ||
|
|
||
| const photosRootUuid = await this.getOrCreatePhotosRoot(); | ||
| const deviceUuid = await this.getOrCreateDeviceFolder(deviceId, photosRootUuid); | ||
| const yearUuid = await this.getOrCreateYearFolder(deviceId, year, deviceUuid); | ||
| const monthUuid = await this.getOrCreateMonthFolder(deviceId, year, month, yearUuid); | ||
|
|
||
| logger.info(`[PhotoBackupFolders] Creating day folder ${dayKey}`); | ||
| const dayUuid = await createFolderWithMerge(monthUuid, day); | ||
| this.dayFolderUuid.set(dayKey, dayUuid); | ||
| return dayUuid; | ||
| } | ||
|
|
||
| clearCache(): void { | ||
| this.photosRootUuid = null; | ||
| this.deviceFolderUuid.clear(); | ||
| this.yearFolderUuid.clear(); | ||
| this.monthFolderUuid.clear(); | ||
| this.dayFolderUuid.clear(); | ||
| } | ||
|
|
||
| private async getOrCreatePhotosRoot(): Promise<string> { | ||
| if (this.photosRootUuid) return this.photosRootUuid; | ||
|
|
||
| const user = await asyncStorageService.getUser(); | ||
| this.photosRootUuid = await createFolderWithMerge(user.rootFolderId, PHOTOS_BACKUP_ROOT_NAME); | ||
| return this.photosRootUuid; | ||
| } | ||
|
|
||
| private async getOrCreateFolder( | ||
| cache: Map<string, string>, | ||
| key: string, | ||
| parentUuid: string, | ||
| name: string, | ||
| ): Promise<string> { | ||
| const cached = cache.get(key); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const uuid = await createFolderWithMerge(parentUuid, name); | ||
| cache.set(key, uuid); | ||
| return uuid; | ||
| } | ||
|
|
||
| private getOrCreateDeviceFolder(deviceId: string, photosRootUuid: string): Promise<string> { | ||
| return this.getOrCreateFolder(this.deviceFolderUuid, deviceId, photosRootUuid, deviceId); | ||
| } | ||
|
|
||
| private getOrCreateYearFolder(deviceId: string, year: string, deviceUuid: string): Promise<string> { | ||
| return this.getOrCreateFolder(this.yearFolderUuid, `${deviceId}/${year}`, deviceUuid, year); | ||
| } | ||
|
|
||
| private getOrCreateMonthFolder(deviceId: string, year: string, month: string, yearUuid: string): Promise<string> { | ||
| return this.getOrCreateFolder(this.monthFolderUuid, `${deviceId}/${year}/${month}`, yearUuid, month); | ||
| } | ||
| } | ||
|
|
||
| export const photoBackupFolders = new PhotoBackupFolderService(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,14 @@ | ||
| import uuid from 'react-native-uuid'; | ||
| import asyncStorageService from 'src/services/AsyncStorageService'; | ||
| import secureStorageService from 'src/services/SecureStorageService'; | ||
| import { AsyncStorageKey } from 'src/types'; | ||
|
|
||
| export const PhotoDeviceId = { | ||
| async getOrCreate(): Promise<string> { | ||
| const existing = await asyncStorageService.getItem(AsyncStorageKey.PhotosDeviceId); | ||
| const existing = await secureStorageService.getItem(AsyncStorageKey.PhotosDeviceId); | ||
| if (existing) return existing; | ||
|
|
||
| const id = uuid.v4() as string; | ||
| await asyncStorageService.saveItem(AsyncStorageKey.PhotosDeviceId, id); | ||
| await secureStorageService.setItem(AsyncStorageKey.PhotosDeviceId, id); | ||
| return id; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import * as MediaLibrary from 'expo-media-library'; | ||
| import pLimit from 'p-limit'; | ||
| import { PhotoUploadService } from './PhotoUploadService'; | ||
|
|
||
| const UPLOAD_CONCURRENCY = 3; | ||
|
|
||
| export interface AssetUploadJob { | ||
| asset: MediaLibrary.Asset; | ||
| existingRemoteFileId?: string; | ||
| } | ||
|
|
||
| interface UploadQueueCallbacks { | ||
| onAssetStart?: (assetId: string) => void; | ||
| onAssetProgress?: (assetId: string, ratio: number) => void; | ||
| onAssetDone?: (assetId: string, remoteFileId: string, modificationTime: number) => void; | ||
| onAssetError?: (assetId: string, error: Error) => void; | ||
| } | ||
|
|
||
| export const PhotoUploadQueue = { | ||
| async start( | ||
| jobs: AssetUploadJob[], | ||
| deviceId: string, | ||
| callbacks: UploadQueueCallbacks, | ||
| ): Promise<void> { | ||
| const limit = pLimit(UPLOAD_CONCURRENCY); | ||
|
|
||
| await Promise.all( | ||
| jobs.map((job) => | ||
| limit(async () => { | ||
| const { asset, existingRemoteFileId } = job; | ||
| callbacks.onAssetStart?.(asset.id); | ||
| try { | ||
| const remoteFileId = existingRemoteFileId | ||
| ? await PhotoUploadService.replace(asset, existingRemoteFileId, deviceId, (ratio) => | ||
| callbacks.onAssetProgress?.(asset.id, ratio), | ||
| ) | ||
| : await PhotoUploadService.upload(asset, deviceId, (ratio) => | ||
| callbacks.onAssetProgress?.(asset.id, ratio), | ||
| ); | ||
| callbacks.onAssetDone?.(asset.id, remoteFileId, asset.modificationTime); | ||
| } catch (uploadError) { | ||
| callbacks.onAssetError?.(asset.id, uploadError as Error); | ||
| } | ||
| }), | ||
| ), | ||
| ); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It’s an early return. Initially, the
scanAllandgetAssetsByIdsfunctions were separate, when asset retrieval was consolidated using theMediaLibrarylibrary, this was added to stop the process. Perhaps I should separate them again, as havingonAssetFetchedreturn a boolean to indicate whether to stop paginating assets or not is a bit confusing