From e73abe076238461066e5fde32c82de4f7867d0da Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 15 Jul 2025 14:50:13 -0400 Subject: [PATCH 01/45] refactor: enum casing (#19946) --- server/src/app.module.ts | 4 +- server/src/config.ts | 50 +- server/src/constants.ts | 10 +- server/src/controllers/activity.controller.ts | 8 +- server/src/controllers/album.controller.ts | 12 +- .../controllers/api-key.controller.spec.ts | 2 +- server/src/controllers/api-key.controller.ts | 10 +- .../src/controllers/asset-media.controller.ts | 4 +- server/src/controllers/asset.controller.ts | 2 +- server/src/controllers/auth.controller.ts | 14 +- server/src/controllers/face.controller.ts | 8 +- server/src/controllers/library.controller.ts | 14 +- server/src/controllers/memory.controller.ts | 12 +- .../controllers/notification.controller.ts | 12 +- server/src/controllers/oauth.controller.ts | 14 +- server/src/controllers/partner.controller.ts | 8 +- server/src/controllers/person.controller.ts | 22 +- server/src/controllers/session.controller.ts | 12 +- .../src/controllers/shared-link.controller.ts | 14 +- server/src/controllers/stack.controller.ts | 12 +- .../controllers/system-config.controller.ts | 8 +- .../controllers/system-metadata.controller.ts | 8 +- server/src/controllers/tag.controller.ts | 18 +- server/src/controllers/timeline.controller.ts | 4 +- server/src/controllers/trash.controller.ts | 6 +- .../src/controllers/user-admin.controller.ts | 18 +- server/src/controllers/user.controller.ts | 2 +- server/src/cores/storage.core.ts | 42 +- server/src/decorators.ts | 6 +- server/src/dtos/album.dto.ts | 2 +- server/src/dtos/asset-response.dto.ts | 2 +- server/src/dtos/asset.dto.ts | 4 +- server/src/dtos/auth.dto.ts | 2 +- server/src/dtos/job.dto.ts | 30 +- server/src/dtos/memory.dto.ts | 2 +- server/src/dtos/search.dto.ts | 2 +- server/src/dtos/system-config.dto.ts | 28 +- server/src/dtos/user-preferences.dto.ts | 2 +- server/src/dtos/user.dto.ts | 2 +- server/src/enum.ts | 655 +++++++++--------- server/src/main.ts | 6 +- .../middleware/asset-upload.interceptor.ts | 2 +- server/src/middleware/auth.guard.ts | 8 +- .../src/middleware/file-upload.interceptor.ts | 4 +- .../1718486162779-AddFaceSearchRelation.ts | 4 +- server/src/repositories/access.repository.ts | 8 +- .../src/repositories/activity.repository.ts | 2 +- .../src/repositories/album-user.repository.ts | 2 +- .../src/repositories/asset-job.repository.ts | 20 +- server/src/repositories/asset.repository.ts | 32 +- server/src/repositories/audit.repository.ts | 2 +- .../repositories/config.repository.spec.ts | 4 +- server/src/repositories/config.repository.ts | 22 +- .../src/repositories/database.repository.ts | 36 +- .../src/repositories/download.repository.ts | 2 +- .../src/repositories/duplicate.repository.ts | 4 +- server/src/repositories/event.repository.ts | 2 +- server/src/repositories/job.repository.ts | 14 +- server/src/repositories/library.repository.ts | 4 +- .../repositories/logging.repository.spec.ts | 4 +- server/src/repositories/logging.repository.ts | 32 +- server/src/repositories/map.repository.ts | 10 +- server/src/repositories/media.repository.ts | 12 +- server/src/repositories/memory.repository.ts | 6 +- server/src/repositories/move.repository.ts | 4 +- server/src/repositories/oauth.repository.ts | 4 +- server/src/repositories/person.repository.ts | 8 +- server/src/repositories/search.repository.ts | 14 +- .../repositories/shared-link.repository.ts | 6 +- .../src/repositories/telemetry.repository.ts | 12 +- server/src/repositories/trash.repository.ts | 14 +- server/src/repositories/user.repository.ts | 14 +- server/src/repositories/view-repository.ts | 4 +- .../1744910873969-InitialMigration.ts | 420 ++++++++--- .../1749067526135-UserOnboardingDefault.ts | 4 +- server/src/schema/tables/album-user.table.ts | 2 +- server/src/schema/tables/album.table.ts | 2 +- server/src/schema/tables/asset-face.table.ts | 2 +- server/src/schema/tables/asset.table.ts | 4 +- server/src/schema/tables/user.table.ts | 2 +- server/src/services/activity.service.ts | 8 +- server/src/services/album.service.spec.ts | 26 +- server/src/services/album.service.ts | 20 +- server/src/services/api-key.service.spec.ts | 18 +- .../src/services/asset-media.service.spec.ts | 38 +- server/src/services/asset-media.service.ts | 36 +- server/src/services/asset.service.spec.ts | 64 +- server/src/services/asset.service.ts | 46 +- server/src/services/audit.service.spec.ts | 2 +- server/src/services/audit.service.ts | 4 +- server/src/services/auth.service.spec.ts | 10 +- server/src/services/auth.service.ts | 22 +- server/src/services/backup.service.spec.ts | 18 +- server/src/services/backup.service.ts | 14 +- server/src/services/database.service.spec.ts | 40 +- server/src/services/database.service.ts | 8 +- server/src/services/download.service.ts | 8 +- server/src/services/duplicate.service.spec.ts | 32 +- server/src/services/duplicate.service.ts | 32 +- server/src/services/job.service.spec.ts | 160 ++--- server/src/services/job.service.ts | 154 ++-- server/src/services/library.service.spec.ts | 78 +-- server/src/services/library.service.ts | 99 ++- server/src/services/media.service.spec.ts | 346 ++++----- server/src/services/media.service.ts | 172 ++--- server/src/services/memory.service.ts | 24 +- server/src/services/metadata.service.spec.ts | 80 +-- server/src/services/metadata.service.ts | 100 +-- .../src/services/notification.service.spec.ts | 76 +- server/src/services/notification.service.ts | 74 +- server/src/services/partner.service.ts | 2 +- server/src/services/person.service.spec.ts | 76 +- server/src/services/person.service.ts | 146 ++-- server/src/services/search.service.ts | 8 +- server/src/services/server.service.spec.ts | 2 +- server/src/services/server.service.ts | 14 +- server/src/services/session.service.spec.ts | 2 +- server/src/services/session.service.ts | 10 +- .../src/services/shared-link.service.spec.ts | 20 +- server/src/services/shared-link.service.ts | 14 +- .../src/services/smart-info.service.spec.ts | 18 +- server/src/services/smart-info.service.ts | 30 +- server/src/services/stack.service.ts | 10 +- .../services/storage-template.service.spec.ts | 68 +- .../src/services/storage-template.service.ts | 32 +- server/src/services/storage.service.spec.ts | 4 +- server/src/services/storage.service.ts | 10 +- server/src/services/sync.service.ts | 10 +- .../services/system-config.service.spec.ts | 48 +- .../services/system-metadata.service.spec.ts | 4 +- .../src/services/system-metadata.service.ts | 8 +- server/src/services/tag.service.spec.ts | 2 +- server/src/services/tag.service.ts | 22 +- server/src/services/timeline.service.spec.ts | 10 +- server/src/services/timeline.service.ts | 14 +- server/src/services/trash.service.spec.ts | 8 +- server/src/services/trash.service.ts | 12 +- .../src/services/user-admin.service.spec.ts | 6 +- server/src/services/user-admin.service.ts | 6 +- server/src/services/user.service.spec.ts | 12 +- server/src/services/user.service.ts | 48 +- server/src/services/version.service.spec.ts | 20 +- server/src/services/version.service.ts | 24 +- server/src/types.ts | 136 ++-- server/src/utils/access.ts | 116 ++-- server/src/utils/asset.util.ts | 16 +- server/src/utils/config.ts | 4 +- server/src/utils/database.ts | 10 +- server/src/utils/file.ts | 6 +- server/src/utils/media.ts | 52 +- server/src/utils/mime-types.ts | 6 +- server/src/utils/misc.ts | 6 +- server/src/utils/preferences.ts | 4 +- server/src/utils/response.ts | 12 +- server/test/fixtures/album.stub.ts | 22 +- server/test/fixtures/asset.stub.ts | 140 ++-- server/test/fixtures/face.stub.ts | 16 +- server/test/fixtures/person.stub.ts | 14 +- server/test/fixtures/shared-link.stub.ts | 32 +- server/test/fixtures/user.stub.ts | 6 +- server/test/medium.factory.ts | 10 +- .../specs/services/auth.service.spec.ts | 4 +- .../specs/services/memory.service.spec.ts | 14 +- .../specs/services/timeline.service.spec.ts | 2 +- .../specs/services/user.service.spec.ts | 14 +- .../specs/services/version.service.spec.ts | 2 +- .../specs/sync/sync-album-asset-exif.spec.ts | 8 +- .../specs/sync/sync-album-asset.spec.ts | 8 +- .../specs/sync/sync-album-to-asset.spec.ts | 4 +- .../medium/specs/sync/sync-album-user.spec.ts | 42 +- .../test/medium/specs/sync/sync-album.spec.ts | 10 +- .../specs/sync/sync-user-metadata.spec.ts | 20 +- .../repositories/config.repository.mock.ts | 8 +- server/test/small.factory.ts | 16 +- 174 files changed, 2675 insertions(+), 2459 deletions(-) diff --git a/server/src/app.module.ts b/server/src/app.module.ts index c06087edea..9ea75d78c4 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -85,13 +85,13 @@ class BaseModule implements OnModuleInit, OnModuleDestroy { @Module({ imports: [...imports, ScheduleModule.forRoot()], controllers: [...controllers], - providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }], + providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }], }) export class ApiModule extends BaseModule {} @Module({ imports: [...imports], - providers: [...common, { provide: IWorker, useValue: ImmichWorker.MICROSERVICES }, SchedulerRegistry], + providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry], }) export class MicroservicesModule extends BaseModule {} diff --git a/server/src/config.ts b/server/src/config.ts index 90ca2c1529..33a6f19ba1 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -8,7 +8,7 @@ import { OAuthTokenEndpointAuthMethod, QueueName, ToneMapping, - TranscodeHWAccel, + TranscodeHardwareAcceleration, TranscodePolicy, VideoCodec, VideoContainer, @@ -42,7 +42,7 @@ export interface SystemConfig { twoPass: boolean; preferredHwDevice: string; transcode: TranscodePolicy; - accel: TranscodeHWAccel; + accel: TranscodeHardwareAcceleration; accelDecode: boolean; tonemap: ToneMapping; }; @@ -190,39 +190,39 @@ export const defaults = Object.freeze({ preset: 'ultrafast', targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], - targetAudioCodec: AudioCodec.AAC, - acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS, AudioCodec.PCMS16LE], - acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], + targetAudioCodec: AudioCodec.Aac, + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus, AudioCodec.PcmS16le], + acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], targetResolution: '720', maxBitrate: '0', bframes: -1, refs: 0, gopSize: 0, temporalAQ: false, - cqMode: CQMode.AUTO, + cqMode: CQMode.Auto, twoPass: false, preferredHwDevice: 'auto', - transcode: TranscodePolicy.REQUIRED, - tonemap: ToneMapping.HABLE, - accel: TranscodeHWAccel.DISABLED, + transcode: TranscodePolicy.Required, + tonemap: ToneMapping.Hable, + accel: TranscodeHardwareAcceleration.Disabled, accelDecode: false, }, job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, - [QueueName.SMART_SEARCH]: { concurrency: 2 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 5 }, - [QueueName.FACE_DETECTION]: { concurrency: 2 }, - [QueueName.SEARCH]: { concurrency: 5 }, - [QueueName.SIDECAR]: { concurrency: 5 }, - [QueueName.LIBRARY]: { concurrency: 5 }, - [QueueName.MIGRATION]: { concurrency: 5 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 3 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, + [QueueName.BackgroundTask]: { concurrency: 5 }, + [QueueName.SmartSearch]: { concurrency: 2 }, + [QueueName.MetadataExtraction]: { concurrency: 5 }, + [QueueName.FaceDetection]: { concurrency: 2 }, + [QueueName.Search]: { concurrency: 5 }, + [QueueName.Sidecar]: { concurrency: 5 }, + [QueueName.Library]: { concurrency: 5 }, + [QueueName.Migration]: { concurrency: 5 }, + [QueueName.ThumbnailGeneration]: { concurrency: 3 }, + [QueueName.VideoConversion]: { concurrency: 1 }, + [QueueName.Notification]: { concurrency: 5 }, }, logging: { enabled: true, - level: LogLevel.LOG, + level: LogLevel.Log, }, machineLearning: { enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', @@ -273,7 +273,7 @@ export const defaults = Object.freeze({ storageLabelClaim: 'preferred_username', storageQuotaClaim: 'immich_quota', roleClaim: 'immich_role', - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST, + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost, timeout: 30_000, }, passwordLogin: { @@ -286,12 +286,12 @@ export const defaults = Object.freeze({ }, image: { thumbnail: { - format: ImageFormat.WEBP, + format: ImageFormat.Webp, size: 250, quality: 80, }, preview: { - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, size: 1440, quality: 80, }, @@ -299,7 +299,7 @@ export const defaults = Object.freeze({ extractEmbedded: false, fullsize: { enabled: false, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, }, }, diff --git a/server/src/constants.ts b/server/src/constants.ts index 2e25797938..447d8a09c9 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -25,14 +25,14 @@ export const EXTENSION_NAMES: Record = { } as const; export const VECTOR_EXTENSIONS = [ - DatabaseExtension.VECTORCHORD, - DatabaseExtension.VECTORS, - DatabaseExtension.VECTOR, + DatabaseExtension.VectorChord, + DatabaseExtension.Vectors, + DatabaseExtension.Vector, ] as const; export const VECTOR_INDEX_TABLES = { - [VectorIndex.CLIP]: 'smart_search', - [VectorIndex.FACE]: 'face_search', + [VectorIndex.Clip]: 'smart_search', + [VectorIndex.Face]: 'face_search', } as const; export const VECTORCHORD_LIST_SLACK_FACTOR = 1.2; diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index b91f2902d5..d2d34da102 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -20,13 +20,13 @@ export class ActivityController { constructor(private service: ActivityService) {} @Get() - @Authenticated({ permission: Permission.ACTIVITY_READ }) + @Authenticated({ permission: Permission.ActivityRead }) getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise { return this.service.getAll(auth, dto); } @Post() - @Authenticated({ permission: Permission.ACTIVITY_CREATE }) + @Authenticated({ permission: Permission.ActivityCreate }) async createActivity( @Auth() auth: AuthDto, @Body() dto: ActivityCreateDto, @@ -40,14 +40,14 @@ export class ActivityController { } @Get('statistics') - @Authenticated({ permission: Permission.ACTIVITY_STATISTICS }) + @Authenticated({ permission: Permission.ActivityStatistics }) getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { return this.service.getStatistics(auth, dto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.ACTIVITY_DELETE }) + @Authenticated({ permission: Permission.ActivityDelete }) deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 49ec5a82ea..2e6d1e7e43 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -23,24 +23,24 @@ export class AlbumController { constructor(private service: AlbumService) {} @Get() - @Authenticated({ permission: Permission.ALBUM_READ }) + @Authenticated({ permission: Permission.AlbumRead }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { return this.service.getAll(auth, query); } @Post() - @Authenticated({ permission: Permission.ALBUM_CREATE }) + @Authenticated({ permission: Permission.AlbumCreate }) createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise { return this.service.create(auth, dto); } @Get('statistics') - @Authenticated({ permission: Permission.ALBUM_STATISTICS }) + @Authenticated({ permission: Permission.AlbumStatistics }) getAlbumStatistics(@Auth() auth: AuthDto): Promise { return this.service.getStatistics(auth); } - @Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true }) + @Authenticated({ permission: Permission.AlbumRead, sharedLink: true }) @Get(':id') getAlbumInfo( @Auth() auth: AuthDto, @@ -51,7 +51,7 @@ export class AlbumController { } @Patch(':id') - @Authenticated({ permission: Permission.ALBUM_UPDATE }) + @Authenticated({ permission: Permission.AlbumUpdate }) updateAlbumInfo( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -61,7 +61,7 @@ export class AlbumController { } @Delete(':id') - @Authenticated({ permission: Permission.ALBUM_DELETE }) + @Authenticated({ permission: Permission.AlbumDelete }) deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { return this.service.delete(auth, id); } diff --git a/server/src/controllers/api-key.controller.spec.ts b/server/src/controllers/api-key.controller.spec.ts index f58a53723a..993ad012cc 100644 --- a/server/src/controllers/api-key.controller.spec.ts +++ b/server/src/controllers/api-key.controller.spec.ts @@ -55,7 +55,7 @@ describe(APIKeyController.name, () => { it('should require a valid uuid', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/api-keys/123`) - .send({ name: 'new name', permissions: [Permission.ALL] }); + .send({ name: 'new name', permissions: [Permission.All] }); expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index 08efd753cf..6347a1274a 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -13,25 +13,25 @@ export class APIKeyController { constructor(private service: ApiKeyService) {} @Post() - @Authenticated({ permission: Permission.API_KEY_CREATE }) + @Authenticated({ permission: Permission.ApiKeyCreate }) createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated({ permission: Permission.API_KEY_READ }) + @Authenticated({ permission: Permission.ApiKeyRead }) getApiKeys(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get(':id') - @Authenticated({ permission: Permission.API_KEY_READ }) + @Authenticated({ permission: Permission.ApiKeyRead }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.API_KEY_UPDATE }) + @Authenticated({ permission: Permission.ApiKeyUpdate }) updateApiKey( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -42,7 +42,7 @@ export class APIKeyController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.API_KEY_DELETE }) + @Authenticated({ permission: Permission.ApiKeyDelete }) deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index b2c9397580..ea6c9602c8 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -45,7 +45,7 @@ import { ImmichFileResponse, sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(RouteKey.ASSET) +@Controller(RouteKey.Asset) export class AssetMediaController { constructor( private logger: LoggingRepository, @@ -56,7 +56,7 @@ export class AssetMediaController { @UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor) @ApiConsumes('multipart/form-data') @ApiHeader({ - name: ImmichHeader.CHECKSUM, + name: ImmichHeader.Checksum, description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded', required: false, }) diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 925b64c8a8..bb17daddf3 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -19,7 +19,7 @@ import { AssetService } from 'src/services/asset.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(RouteKey.ASSET) +@Controller(RouteKey.Asset) export class AssetController { constructor(private service: AssetService) {} diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 78c611d761..9bc5fd0fbb 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -36,9 +36,9 @@ export class AuthController { return respondWithCookie(res, body, { isSecure: loginDetails.isSecure, values: [ - { key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken }, - { key: ImmichCookie.AUTH_TYPE, value: AuthType.PASSWORD }, - { key: ImmichCookie.IS_AUTHENTICATED, value: 'true' }, + { key: ImmichCookie.AccessToken, value: body.accessToken }, + { key: ImmichCookie.AuthType, value: AuthType.Password }, + { key: ImmichCookie.IsAuthenticated, value: 'true' }, ], }); } @@ -70,13 +70,13 @@ export class AuthController { @Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, ): Promise { - const authType = (request.cookies || {})[ImmichCookie.AUTH_TYPE]; + const authType = (request.cookies || {})[ImmichCookie.AuthType]; const body = await this.service.logout(auth, authType); return respondWithoutCookie(res, body, [ - ImmichCookie.ACCESS_TOKEN, - ImmichCookie.AUTH_TYPE, - ImmichCookie.IS_AUTHENTICATED, + ImmichCookie.AccessToken, + ImmichCookie.AuthType, + ImmichCookie.IsAuthenticated, ]); } diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index d94cd532f7..20b6db6039 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -19,19 +19,19 @@ export class FaceController { constructor(private service: PersonService) {} @Post() - @Authenticated({ permission: Permission.FACE_CREATE }) + @Authenticated({ permission: Permission.FaceCreate }) createFace(@Auth() auth: AuthDto, @Body() dto: AssetFaceCreateDto) { return this.service.createFace(auth, dto); } @Get() - @Authenticated({ permission: Permission.FACE_READ }) + @Authenticated({ permission: Permission.FaceRead }) getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise { return this.service.getFacesById(auth, dto); } @Put(':id') - @Authenticated({ permission: Permission.FACE_UPDATE }) + @Authenticated({ permission: Permission.FaceUpdate }) reassignFacesById( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -41,7 +41,7 @@ export class FaceController { } @Delete(':id') - @Authenticated({ permission: Permission.FACE_DELETE }) + @Authenticated({ permission: Permission.FaceDelete }) deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) { return this.service.deleteFace(auth, id, dto); } diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index b8959ca288..e090586f57 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -19,32 +19,32 @@ export class LibraryController { constructor(private service: LibraryService) {} @Get() - @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) + @Authenticated({ permission: Permission.LibraryRead, admin: true }) getAllLibraries(): Promise { return this.service.getAll(); } @Post() - @Authenticated({ permission: Permission.LIBRARY_CREATE, admin: true }) + @Authenticated({ permission: Permission.LibraryCreate, admin: true }) createLibrary(@Body() dto: CreateLibraryDto): Promise { return this.service.create(dto); } @Get(':id') - @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) + @Authenticated({ permission: Permission.LibraryRead, admin: true }) getLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); } @Put(':id') - @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + @Authenticated({ permission: Permission.LibraryUpdate, admin: true }) updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise { return this.service.update(id, dto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true }) + @Authenticated({ permission: Permission.LibraryDelete, admin: true }) deleteLibrary(@Param() { id }: UUIDParamDto): Promise { return this.service.delete(id); } @@ -58,14 +58,14 @@ export class LibraryController { } @Get(':id/statistics') - @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) + @Authenticated({ permission: Permission.LibraryStatistics, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(id); } @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true }) + @Authenticated({ permission: Permission.LibraryUpdate, admin: true }) scanLibrary(@Param() { id }: UUIDParamDto) { return this.service.queueScan(id); } diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index d33c5ec22c..a5bbbd7411 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -20,31 +20,31 @@ export class MemoryController { constructor(private service: MemoryService) {} @Get() - @Authenticated({ permission: Permission.MEMORY_READ }) + @Authenticated({ permission: Permission.MemoryRead }) searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.search(auth, dto); } @Post() - @Authenticated({ permission: Permission.MEMORY_CREATE }) + @Authenticated({ permission: Permission.MemoryCreate }) createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { return this.service.create(auth, dto); } @Get('statistics') - @Authenticated({ permission: Permission.MEMORY_READ }) + @Authenticated({ permission: Permission.MemoryRead }) memoriesStatistics(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise { return this.service.statistics(auth, dto); } @Get(':id') - @Authenticated({ permission: Permission.MEMORY_READ }) + @Authenticated({ permission: Permission.MemoryRead }) getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.MEMORY_UPDATE }) + @Authenticated({ permission: Permission.MemoryUpdate }) updateMemory( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -55,7 +55,7 @@ export class MemoryController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.MEMORY_DELETE }) + @Authenticated({ permission: Permission.MemoryDelete }) deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/notification.controller.ts b/server/src/controllers/notification.controller.ts index c64f786850..af4eb198b6 100644 --- a/server/src/controllers/notification.controller.ts +++ b/server/src/controllers/notification.controller.ts @@ -19,31 +19,31 @@ export class NotificationController { constructor(private service: NotificationService) {} @Get() - @Authenticated({ permission: Permission.NOTIFICATION_READ }) + @Authenticated({ permission: Permission.NotificationRead }) getNotifications(@Auth() auth: AuthDto, @Query() dto: NotificationSearchDto): Promise { return this.service.search(auth, dto); } @Put() - @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + @Authenticated({ permission: Permission.NotificationUpdate }) updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise { return this.service.updateAll(auth, dto); } @Delete() - @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + @Authenticated({ permission: Permission.NotificationDelete }) deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') - @Authenticated({ permission: Permission.NOTIFICATION_READ }) + @Authenticated({ permission: Permission.NotificationRead }) getNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.NOTIFICATION_UPDATE }) + @Authenticated({ permission: Permission.NotificationUpdate }) updateNotification( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -53,7 +53,7 @@ export class NotificationController { } @Delete(':id') - @Authenticated({ permission: Permission.NOTIFICATION_DELETE }) + @Authenticated({ permission: Permission.NotificationDelete }) deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index 23ddff5ddc..7da75f573a 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -41,8 +41,8 @@ export class OAuthController { { isSecure: loginDetails.isSecure, values: [ - { key: ImmichCookie.OAUTH_STATE, value: state }, - { key: ImmichCookie.OAUTH_CODE_VERIFIER, value: codeVerifier }, + { key: ImmichCookie.OAuthState, value: state }, + { key: ImmichCookie.OAuthCodeVerifier, value: codeVerifier }, ], }, ); @@ -56,14 +56,14 @@ export class OAuthController { @GetLoginDetails() loginDetails: LoginDetails, ): Promise { const body = await this.service.callback(dto, request.headers, loginDetails); - res.clearCookie(ImmichCookie.OAUTH_STATE); - res.clearCookie(ImmichCookie.OAUTH_CODE_VERIFIER); + res.clearCookie(ImmichCookie.OAuthState); + res.clearCookie(ImmichCookie.OAuthCodeVerifier); return respondWithCookie(res, body, { isSecure: loginDetails.isSecure, values: [ - { key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken }, - { key: ImmichCookie.AUTH_TYPE, value: AuthType.OAUTH }, - { key: ImmichCookie.IS_AUTHENTICATED, value: 'true' }, + { key: ImmichCookie.AccessToken, value: body.accessToken }, + { key: ImmichCookie.AuthType, value: AuthType.OAuth }, + { key: ImmichCookie.IsAuthenticated, value: 'true' }, ], }); } diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 6830fdd52f..6b6efaa570 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -13,19 +13,19 @@ export class PartnerController { constructor(private service: PartnerService) {} @Get() - @Authenticated({ permission: Permission.PARTNER_READ }) + @Authenticated({ permission: Permission.PartnerRead }) getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise { return this.service.search(auth, dto); } @Post(':id') - @Authenticated({ permission: Permission.PARTNER_CREATE }) + @Authenticated({ permission: Permission.PartnerCreate }) createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.create(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.PARTNER_UPDATE }) + @Authenticated({ permission: Permission.PartnerUpdate }) updatePartner( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -35,7 +35,7 @@ export class PartnerController { } @Delete(':id') - @Authenticated({ permission: Permission.PARTNER_DELETE }) + @Authenticated({ permission: Permission.PartnerDelete }) removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index a9f6616426..ec66f7a9ca 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -45,38 +45,38 @@ export class PersonController { } @Get() - @Authenticated({ permission: Permission.PERSON_READ }) + @Authenticated({ permission: Permission.PersonRead }) getAllPeople(@Auth() auth: AuthDto, @Query() options: PersonSearchDto): Promise { return this.service.getAll(auth, options); } @Post() - @Authenticated({ permission: Permission.PERSON_CREATE }) + @Authenticated({ permission: Permission.PersonCreate }) createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise { return this.service.create(auth, dto); } @Put() - @Authenticated({ permission: Permission.PERSON_UPDATE }) + @Authenticated({ permission: Permission.PersonUpdate }) updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updateAll(auth, dto); } @Delete() @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.PERSON_DELETE }) + @Authenticated({ permission: Permission.PersonDelete }) deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') - @Authenticated({ permission: Permission.PERSON_READ }) + @Authenticated({ permission: Permission.PersonRead }) getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getById(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.PERSON_UPDATE }) + @Authenticated({ permission: Permission.PersonUpdate }) updatePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -87,20 +87,20 @@ export class PersonController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.PERSON_DELETE }) + @Authenticated({ permission: Permission.PersonDelete }) deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } @Get(':id/statistics') - @Authenticated({ permission: Permission.PERSON_STATISTICS }) + @Authenticated({ permission: Permission.PersonStatistics }) getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(auth, id); } @Get(':id/thumbnail') @FileResponse() - @Authenticated({ permission: Permission.PERSON_READ }) + @Authenticated({ permission: Permission.PersonRead }) async getPersonThumbnail( @Res() res: Response, @Next() next: NextFunction, @@ -111,7 +111,7 @@ export class PersonController { } @Put(':id/reassign') - @Authenticated({ permission: Permission.PERSON_REASSIGN }) + @Authenticated({ permission: Permission.PersonReassign }) reassignFaces( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -121,7 +121,7 @@ export class PersonController { } @Post(':id/merge') - @Authenticated({ permission: Permission.PERSON_MERGE }) + @Authenticated({ permission: Permission.PersonMerge }) mergePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/session.controller.ts b/server/src/controllers/session.controller.ts index f5eb10b3dd..cbe8158fee 100644 --- a/server/src/controllers/session.controller.ts +++ b/server/src/controllers/session.controller.ts @@ -13,26 +13,26 @@ export class SessionController { constructor(private service: SessionService) {} @Post() - @Authenticated({ permission: Permission.SESSION_CREATE }) + @Authenticated({ permission: Permission.SessionCreate }) createSession(@Auth() auth: AuthDto, @Body() dto: SessionCreateDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated({ permission: Permission.SESSION_READ }) + @Authenticated({ permission: Permission.SessionRead }) getSessions(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Delete() - @Authenticated({ permission: Permission.SESSION_DELETE }) + @Authenticated({ permission: Permission.SessionDelete }) @HttpCode(HttpStatus.NO_CONTENT) deleteAllSessions(@Auth() auth: AuthDto): Promise { return this.service.deleteAll(auth); } @Put(':id') - @Authenticated({ permission: Permission.SESSION_UPDATE }) + @Authenticated({ permission: Permission.SessionUpdate }) updateSession( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -42,14 +42,14 @@ export class SessionController { } @Delete(':id') - @Authenticated({ permission: Permission.SESSION_DELETE }) + @Authenticated({ permission: Permission.SessionDelete }) @HttpCode(HttpStatus.NO_CONTENT) deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } @Post(':id/lock') - @Authenticated({ permission: Permission.SESSION_LOCK }) + @Authenticated({ permission: Permission.SessionLock }) @HttpCode(HttpStatus.NO_CONTENT) lockSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.lock(auth, id); diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index ca978f03da..273d625ca7 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -24,7 +24,7 @@ export class SharedLinkController { constructor(private service: SharedLinkService) {} @Get() - @Authenticated({ permission: Permission.SHARED_LINK_READ }) + @Authenticated({ permission: Permission.SharedLinkRead }) getAllSharedLinks(@Auth() auth: AuthDto, @Query() dto: SharedLinkSearchDto): Promise { return this.service.getAll(auth, dto); } @@ -38,31 +38,31 @@ export class SharedLinkController { @Res({ passthrough: true }) res: Response, @GetLoginDetails() loginDetails: LoginDetails, ): Promise { - const sharedLinkToken = request.cookies?.[ImmichCookie.SHARED_LINK_TOKEN]; + const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken]; if (sharedLinkToken) { dto.token = sharedLinkToken; } const body = await this.service.getMine(auth, dto); return respondWithCookie(res, body, { isSecure: loginDetails.isSecure, - values: body.token ? [{ key: ImmichCookie.SHARED_LINK_TOKEN, value: body.token }] : [], + values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [], }); } @Get(':id') - @Authenticated({ permission: Permission.SHARED_LINK_READ }) + @Authenticated({ permission: Permission.SharedLinkRead }) getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Post() - @Authenticated({ permission: Permission.SHARED_LINK_CREATE }) + @Authenticated({ permission: Permission.SharedLinkCreate }) createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) { return this.service.create(auth, dto); } @Patch(':id') - @Authenticated({ permission: Permission.SHARED_LINK_UPDATE }) + @Authenticated({ permission: Permission.SharedLinkUpdate }) updateSharedLink( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -72,7 +72,7 @@ export class SharedLinkController { } @Delete(':id') - @Authenticated({ permission: Permission.SHARED_LINK_DELETE }) + @Authenticated({ permission: Permission.SharedLinkDelete }) removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/stack.controller.ts b/server/src/controllers/stack.controller.ts index 188952eba5..238753734c 100644 --- a/server/src/controllers/stack.controller.ts +++ b/server/src/controllers/stack.controller.ts @@ -14,32 +14,32 @@ export class StackController { constructor(private service: StackService) {} @Get() - @Authenticated({ permission: Permission.STACK_READ }) + @Authenticated({ permission: Permission.StackRead }) searchStacks(@Auth() auth: AuthDto, @Query() query: StackSearchDto): Promise { return this.service.search(auth, query); } @Post() - @Authenticated({ permission: Permission.STACK_CREATE }) + @Authenticated({ permission: Permission.StackCreate }) createStack(@Auth() auth: AuthDto, @Body() dto: StackCreateDto): Promise { return this.service.create(auth, dto); } @Delete() - @Authenticated({ permission: Permission.STACK_DELETE }) + @Authenticated({ permission: Permission.StackDelete }) @HttpCode(HttpStatus.NO_CONTENT) deleteStacks(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.deleteAll(auth, dto); } @Get(':id') - @Authenticated({ permission: Permission.STACK_READ }) + @Authenticated({ permission: Permission.StackRead }) getStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.STACK_UPDATE }) + @Authenticated({ permission: Permission.StackUpdate }) updateStack( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -50,7 +50,7 @@ export class StackController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.STACK_DELETE }) + @Authenticated({ permission: Permission.StackDelete }) deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); } diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index 58e8bde87b..69117f4d45 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -15,25 +15,25 @@ export class SystemConfigController { ) {} @Get() - @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) + @Authenticated({ permission: Permission.SystemConfigRead, admin: true }) getConfig(): Promise { return this.service.getSystemConfig(); } @Get('defaults') - @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) + @Authenticated({ permission: Permission.SystemConfigRead, admin: true }) getConfigDefaults(): SystemConfigDto { return this.service.getDefaults(); } @Put() - @Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true }) + @Authenticated({ permission: Permission.SystemConfigUpdate, admin: true }) updateConfig(@Body() dto: SystemConfigDto): Promise { return this.service.updateSystemConfig(dto); } @Get('storage-template-options') - @Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true }) + @Authenticated({ permission: Permission.SystemConfigRead, admin: true }) getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { return this.storageTemplateService.getStorageTemplateOptions(); } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts index 71c37d02c4..ad2245a391 100644 --- a/server/src/controllers/system-metadata.controller.ts +++ b/server/src/controllers/system-metadata.controller.ts @@ -15,26 +15,26 @@ export class SystemMetadataController { constructor(private service: SystemMetadataService) {} @Get('admin-onboarding') - @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) + @Authenticated({ permission: Permission.SystemMetadataRead, admin: true }) getAdminOnboarding(): Promise { return this.service.getAdminOnboarding(); } @Post('admin-onboarding') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.SYSTEM_METADATA_UPDATE, admin: true }) + @Authenticated({ permission: Permission.SystemMetadataUpdate, admin: true }) updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise { return this.service.updateAdminOnboarding(dto); } @Get('reverse-geocoding-state') - @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) + @Authenticated({ permission: Permission.SystemMetadataRead, admin: true }) getReverseGeocodingState(): Promise { return this.service.getReverseGeocodingState(); } @Get('version-check-state') - @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) + @Authenticated({ permission: Permission.SystemMetadataRead, admin: true }) getVersionCheckState(): Promise { return this.service.getVersionCheckState(); } diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index cf6b8ac695..4906bc0c6e 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -21,50 +21,50 @@ export class TagController { constructor(private service: TagService) {} @Post() - @Authenticated({ permission: Permission.TAG_CREATE }) + @Authenticated({ permission: Permission.TagCreate }) createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise { return this.service.create(auth, dto); } @Get() - @Authenticated({ permission: Permission.TAG_READ }) + @Authenticated({ permission: Permission.TagRead }) getAllTags(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Put() - @Authenticated({ permission: Permission.TAG_CREATE }) + @Authenticated({ permission: Permission.TagCreate }) upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise { return this.service.upsert(auth, dto); } @Put('assets') - @Authenticated({ permission: Permission.TAG_ASSET }) + @Authenticated({ permission: Permission.TagAsset }) bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise { return this.service.bulkTagAssets(auth, dto); } @Get(':id') - @Authenticated({ permission: Permission.TAG_READ }) + @Authenticated({ permission: Permission.TagRead }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.TAG_UPDATE }) + @Authenticated({ permission: Permission.TagUpdate }) updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise { return this.service.update(auth, id, dto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - @Authenticated({ permission: Permission.TAG_DELETE }) + @Authenticated({ permission: Permission.TagDelete }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } @Put(':id/assets') - @Authenticated({ permission: Permission.TAG_ASSET }) + @Authenticated({ permission: Permission.TagAsset }) tagAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -74,7 +74,7 @@ export class TagController { } @Delete(':id/assets') - @Authenticated({ permission: Permission.TAG_ASSET }) + @Authenticated({ permission: Permission.TagAsset }) untagAssets( @Auth() auth: AuthDto, @Body() dto: BulkIdsDto, diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index b4ee042625..8cab840ec8 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -12,13 +12,13 @@ export class TimelineController { constructor(private service: TimelineService) {} @Get('buckets') - @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) + @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) { return this.service.getTimeBuckets(auth, dto); } @Get('bucket') - @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) + @Authenticated({ permission: Permission.AssetRead, sharedLink: true }) @ApiOkResponse({ type: TimeBucketAssetResponseDto }) @Header('Content-Type', 'application/json') getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { diff --git a/server/src/controllers/trash.controller.ts b/server/src/controllers/trash.controller.ts index dfcdfa6ba2..1bb46e4f98 100644 --- a/server/src/controllers/trash.controller.ts +++ b/server/src/controllers/trash.controller.ts @@ -14,21 +14,21 @@ export class TrashController { @Post('empty') @HttpCode(HttpStatus.OK) - @Authenticated({ permission: Permission.ASSET_DELETE }) + @Authenticated({ permission: Permission.AssetDelete }) emptyTrash(@Auth() auth: AuthDto): Promise { return this.service.empty(auth); } @Post('restore') @HttpCode(HttpStatus.OK) - @Authenticated({ permission: Permission.ASSET_DELETE }) + @Authenticated({ permission: Permission.AssetDelete }) restoreTrash(@Auth() auth: AuthDto): Promise { return this.service.restore(auth); } @Post('restore/assets') @HttpCode(HttpStatus.OK) - @Authenticated({ permission: Permission.ASSET_DELETE }) + @Authenticated({ permission: Permission.AssetDelete }) restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise { return this.service.restoreAssets(auth, dto); } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index 83d7caef08..d50bd174ad 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -21,25 +21,25 @@ export class UserAdminController { constructor(private service: UserAdminService) {} @Get() - @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) + @Authenticated({ permission: Permission.AdminUserRead, admin: true }) searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise { return this.service.search(auth, dto); } @Post() - @Authenticated({ permission: Permission.ADMIN_USER_CREATE, admin: true }) + @Authenticated({ permission: Permission.AdminUserCreate, admin: true }) createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise { return this.service.create(createUserDto); } @Get(':id') - @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) + @Authenticated({ permission: Permission.AdminUserRead, admin: true }) getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } @Put(':id') - @Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true }) + @Authenticated({ permission: Permission.AdminUserUpdate, admin: true }) updateUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -49,7 +49,7 @@ export class UserAdminController { } @Delete(':id') - @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) + @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) deleteUserAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -59,7 +59,7 @@ export class UserAdminController { } @Get(':id/statistics') - @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) + @Authenticated({ permission: Permission.AdminUserRead, admin: true }) getUserStatisticsAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -69,13 +69,13 @@ export class UserAdminController { } @Get(':id/preferences') - @Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true }) + @Authenticated({ permission: Permission.AdminUserRead, admin: true }) getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.getPreferences(auth, id); } @Put(':id/preferences') - @Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true }) + @Authenticated({ permission: Permission.AdminUserUpdate, admin: true }) updateUserPreferencesAdmin( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @@ -85,7 +85,7 @@ export class UserAdminController { } @Post(':id/restore') - @Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true }) + @Authenticated({ permission: Permission.AdminUserDelete, admin: true }) @HttpCode(HttpStatus.OK) restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.restore(auth, id); diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 6c6eae15ff..76d2cf4c5a 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -30,7 +30,7 @@ import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Users') -@Controller(RouteKey.USER) +@Controller(RouteKey.User) export class UserController { constructor( private service: UserService, diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 1a8e31e86b..6576b397e3 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -25,8 +25,8 @@ export interface MoveRequest { }; } -export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL | AssetPathType.FULLSIZE; -export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDEO; +export type GeneratedImageType = AssetPathType.Preview | AssetPathType.Thumbnail | AssetPathType.FullSize; +export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo; export type ThumbnailPathEntity = { id: string; ownerId: string }; @@ -79,7 +79,7 @@ export class StorageCore { } static getLibraryFolder(user: { storageLabel: string | null; id: string }) { - return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id); + return join(StorageCore.getBaseFolder(StorageFolder.Library), user.storageLabel || user.id); } static getBaseFolder(folder: StorageFolder) { @@ -87,23 +87,23 @@ export class StorageCore { } static getPersonThumbnailPath(person: ThumbnailPathEntity) { - return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); + return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); } static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { - return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`); + return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`); } static getEncodedVideoPath(asset: ThumbnailPathEntity) { - return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`); + return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`); } static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) { - return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${uuid}-MP.mp4`); + return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${uuid}-MP.mp4`); } static isAndroidMotionPath(originalPath: string) { - return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO)); + return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.EncodedVideo)); } static isImmichPath(path: string) { @@ -130,7 +130,7 @@ export class StorageCore { async moveAssetVideo(asset: StorageAsset) { return this.moveFile({ entityId: asset.id, - pathType: AssetPathType.ENCODED_VIDEO, + pathType: AssetPathType.EncodedVideo, oldPath: asset.encodedVideoPath, newPath: StorageCore.getEncodedVideoPath(asset), }); @@ -139,7 +139,7 @@ export class StorageCore { async movePersonFile(person: { id: string; ownerId: string; thumbnailPath: string }, pathType: PersonPathType) { const { id: entityId, thumbnailPath } = person; switch (pathType) { - case PersonPathType.FACE: { + case PersonPathType.Face: { await this.moveFile({ entityId, pathType, @@ -188,7 +188,7 @@ export class StorageCore { move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath }); } - if (pathType === AssetPathType.ORIGINAL && !assetInfo) { + if (pathType === AssetPathType.Original && !assetInfo) { this.logger.warn(`Unable to complete move. Missing asset info for ${entityId}`); return; } @@ -274,25 +274,25 @@ export class StorageCore { private savePath(pathType: PathType, id: string, newPath: string) { switch (pathType) { - case AssetPathType.ORIGINAL: { + case AssetPathType.Original: { return this.assetRepository.update({ id, originalPath: newPath }); } - case AssetPathType.FULLSIZE: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FULLSIZE, path: newPath }); + case AssetPathType.FullSize: { + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath }); } - case AssetPathType.PREVIEW: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: newPath }); + case AssetPathType.Preview: { + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath }); } - case AssetPathType.THUMBNAIL: { - return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: newPath }); + case AssetPathType.Thumbnail: { + return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath }); } - case AssetPathType.ENCODED_VIDEO: { + case AssetPathType.EncodedVideo: { return this.assetRepository.update({ id, encodedVideoPath: newPath }); } - case AssetPathType.SIDECAR: { + case AssetPathType.Sidecar: { return this.assetRepository.update({ id, sidecarPath: newPath }); } - case PersonPathType.FACE: { + case PersonPathType.Face: { return this.personRepository.update({ id, thumbnailPath: newPath }); } } diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 766e7c70b9..b88f2d2d7e 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -131,7 +131,7 @@ export interface GenerateSqlQueries { } export const Telemetry = (options: { enabled?: boolean }) => - SetMetadata(MetadataKey.TELEMETRY_ENABLED, options?.enabled ?? true); + SetMetadata(MetadataKey.TelemetryEnabled, options?.enabled ?? true); /** Decorator to enable versioning/tracking of generated Sql */ export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options); @@ -145,13 +145,13 @@ export type EventConfig = { /** register events for these workers, defaults to all workers */ workers?: ImmichWorker[]; }; -export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); +export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EventConfig, config); export type JobConfig = { name: JobName; queue: QueueName; }; -export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JOB_CONFIG, config); +export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JobConfig, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index c6cde0894f..3a88ba5be3 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -18,7 +18,7 @@ export class AlbumUserAddDto { @ValidateUUID() userId!: string; - @ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', default: AlbumUserRole.EDITOR }) + @ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', default: AlbumUserRole.Editor }) role?: AlbumUserRole; } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 5b587e59ba..98ed8669f0 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -205,7 +205,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset localDateTime: entity.localDateTime, updatedAt: entity.updatedAt, isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false, - isArchived: entity.visibility === AssetVisibility.ARCHIVE, + isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, visibility: entity.visibility, duration: entity.duration ?? '0:00:00.00000', diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 727ab1625d..5728d21646 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -126,8 +126,8 @@ export class AssetStatsResponseDto { export const mapStats = (stats: AssetStats): AssetStatsResponseDto => { return { - images: stats[AssetType.IMAGE], - videos: stats[AssetType.VIDEO], + images: stats[AssetType.Image], + videos: stats[AssetType.Video], total: Object.values(stats).reduce((total, value) => total + value, 0), }; }; diff --git a/server/src/dtos/auth.dto.ts b/server/src/dtos/auth.dto.ts index e94818b2b5..2bb98b34a5 100644 --- a/server/src/dtos/auth.dto.ts +++ b/server/src/dtos/auth.dto.ts @@ -45,7 +45,7 @@ export class LoginResponseDto { export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginResponseDto { const onboardingMetadata = entity.metadata.find( - (item): item is UserMetadataItem => item.key === UserMetadataKey.ONBOARDING, + (item): item is UserMetadataItem => item.key === UserMetadataKey.Onboarding, )?.value; return { diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 60124c877a..2123b65878 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -50,47 +50,47 @@ export class JobStatusDto { export class AllJobStatusResponseDto implements Record { @ApiProperty({ type: JobStatusDto }) - [QueueName.THUMBNAIL_GENERATION]!: JobStatusDto; + [QueueName.ThumbnailGeneration]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.METADATA_EXTRACTION]!: JobStatusDto; + [QueueName.MetadataExtraction]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.VIDEO_CONVERSION]!: JobStatusDto; + [QueueName.VideoConversion]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.SMART_SEARCH]!: JobStatusDto; + [QueueName.SmartSearch]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.STORAGE_TEMPLATE_MIGRATION]!: JobStatusDto; + [QueueName.StorageTemplateMigration]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.MIGRATION]!: JobStatusDto; + [QueueName.Migration]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.BACKGROUND_TASK]!: JobStatusDto; + [QueueName.BackgroundTask]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.SEARCH]!: JobStatusDto; + [QueueName.Search]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.DUPLICATE_DETECTION]!: JobStatusDto; + [QueueName.DuplicateDetection]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.FACE_DETECTION]!: JobStatusDto; + [QueueName.FaceDetection]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.FACIAL_RECOGNITION]!: JobStatusDto; + [QueueName.FacialRecognition]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.SIDECAR]!: JobStatusDto; + [QueueName.Sidecar]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.LIBRARY]!: JobStatusDto; + [QueueName.Library]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.NOTIFICATION]!: JobStatusDto; + [QueueName.Notification]!: JobStatusDto; @ApiProperty({ type: JobStatusDto }) - [QueueName.BACKUP_DATABASE]!: JobStatusDto; + [QueueName.BackupDatabase]!: JobStatusDto; } diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index e92e11bdfb..a79511c73e 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -50,7 +50,7 @@ export class MemoryCreateDto extends MemoryBaseDto { @ValidateNested() @Type((options) => { switch (options?.object.type) { - case MemoryType.ON_THIS_DAY: { + case MemoryType.OnThisDay: { return OnThisDayDto; } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 85c6fbf0de..aef78e51ea 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -170,7 +170,7 @@ export class MetadataSearchDto extends RandomSearchDto { @Optional() encodedVideoPath?: string; - @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true, default: AssetOrder.DESC }) + @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true, default: AssetOrder.Desc }) order?: AssetOrder; @IsInt() diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 809f381dd6..8a58995de7 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -26,7 +26,7 @@ import { OAuthTokenEndpointAuthMethod, QueueName, ToneMapping, - TranscodeHWAccel, + TranscodeHardwareAcceleration, TranscodePolicy, VideoCodec, VideoContainer, @@ -136,8 +136,8 @@ export class SystemConfigFFmpegDto { @ValidateEnum({ enum: TranscodePolicy, name: 'TranscodePolicy' }) transcode!: TranscodePolicy; - @ValidateEnum({ enum: TranscodeHWAccel, name: 'TranscodeHWAccel' }) - accel!: TranscodeHWAccel; + @ValidateEnum({ enum: TranscodeHardwareAcceleration, name: 'TranscodeHWAccel' }) + accel!: TranscodeHardwareAcceleration; @ValidateBoolean() accelDecode!: boolean; @@ -158,67 +158,67 @@ class SystemConfigJobDto implements Record @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.THUMBNAIL_GENERATION]!: JobSettingsDto; + [QueueName.ThumbnailGeneration]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.METADATA_EXTRACTION]!: JobSettingsDto; + [QueueName.MetadataExtraction]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.VIDEO_CONVERSION]!: JobSettingsDto; + [QueueName.VideoConversion]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.SMART_SEARCH]!: JobSettingsDto; + [QueueName.SmartSearch]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.MIGRATION]!: JobSettingsDto; + [QueueName.Migration]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.BACKGROUND_TASK]!: JobSettingsDto; + [QueueName.BackgroundTask]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.SEARCH]!: JobSettingsDto; + [QueueName.Search]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.FACE_DETECTION]!: JobSettingsDto; + [QueueName.FaceDetection]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.SIDECAR]!: JobSettingsDto; + [QueueName.Sidecar]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.LIBRARY]!: JobSettingsDto; + [QueueName.Library]!: JobSettingsDto; @ApiProperty({ type: JobSettingsDto }) @ValidateNested() @IsObject() @Type(() => JobSettingsDto) - [QueueName.NOTIFICATION]!: JobSettingsDto; + [QueueName.Notification]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index d165438061..b258158ae2 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -157,7 +157,7 @@ export class UserPreferencesUpdateDto { class AlbumsResponse { @ValidateEnum({ enum: AssetOrder, name: 'AssetOrder' }) - defaultAssetOrder: AssetOrder = AssetOrder.DESC; + defaultAssetOrder: AssetOrder = AssetOrder.Desc; } class RatingsResponse { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 3e3a92d42e..0da86bfcb5 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -171,7 +171,7 @@ export class UserAdminResponseDto extends UserResponseDto { export function mapUserAdmin(entity: UserAdmin): UserAdminResponseDto { const metadata = entity.metadata || []; const license = metadata.find( - (item): item is UserMetadataItem => item.key === UserMetadataKey.LICENSE, + (item): item is UserMetadataItem => item.key === UserMetadataKey.License, )?.value; return { ...mapUser(entity), diff --git a/server/src/enum.ts b/server/src/enum.ts index d7c74a71c6..1d8b8645d1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1,402 +1,397 @@ export enum AuthType { - PASSWORD = 'password', - OAUTH = 'oauth', + Password = 'password', + OAuth = 'oauth', } export enum ImmichCookie { - ACCESS_TOKEN = 'immich_access_token', - AUTH_TYPE = 'immich_auth_type', - IS_AUTHENTICATED = 'immich_is_authenticated', - SHARED_LINK_TOKEN = 'immich_shared_link_token', - OAUTH_STATE = 'immich_oauth_state', - OAUTH_CODE_VERIFIER = 'immich_oauth_code_verifier', + AccessToken = 'immich_access_token', + AuthType = 'immich_auth_type', + IsAuthenticated = 'immich_is_authenticated', + SharedLinkToken = 'immich_shared_link_token', + OAuthState = 'immich_oauth_state', + OAuthCodeVerifier = 'immich_oauth_code_verifier', } export enum ImmichHeader { - API_KEY = 'x-api-key', - USER_TOKEN = 'x-immich-user-token', - SESSION_TOKEN = 'x-immich-session-token', - SHARED_LINK_KEY = 'x-immich-share-key', - CHECKSUM = 'x-immich-checksum', - CID = 'x-immich-cid', + ApiKey = 'x-api-key', + UserToken = 'x-immich-user-token', + SessionToken = 'x-immich-session-token', + SharedLinkKey = 'x-immich-share-key', + Checksum = 'x-immich-checksum', + Cid = 'x-immich-cid', } export enum ImmichQuery { - SHARED_LINK_KEY = 'key', - API_KEY = 'apiKey', - SESSION_KEY = 'sessionKey', + SharedLinkKey = 'key', + ApiKey = 'apiKey', + SessionKey = 'sessionKey', } export enum AssetType { - IMAGE = 'IMAGE', - VIDEO = 'VIDEO', - AUDIO = 'AUDIO', - OTHER = 'OTHER', + Image = 'IMAGE', + Video = 'VIDEO', + Audio = 'AUDIO', + Other = 'OTHER', } export enum AssetFileType { /** * An full/large-size image extracted/converted from RAW photos */ - FULLSIZE = 'fullsize', - PREVIEW = 'preview', - THUMBNAIL = 'thumbnail', + FullSize = 'fullsize', + Preview = 'preview', + Thumbnail = 'thumbnail', } export enum AlbumUserRole { - EDITOR = 'editor', - VIEWER = 'viewer', + Editor = 'editor', + Viewer = 'viewer', } export enum AssetOrder { - ASC = 'asc', - DESC = 'desc', + Asc = 'asc', + Desc = 'desc', } export enum DatabaseAction { - CREATE = 'CREATE', - UPDATE = 'UPDATE', - DELETE = 'DELETE', + Create = 'CREATE', + Update = 'UPDATE', + Delete = 'DELETE', } export enum EntityType { - ASSET = 'ASSET', - ALBUM = 'ALBUM', + Asset = 'ASSET', + Album = 'ALBUM', } export enum MemoryType { /** pictures taken on this day X years ago */ - ON_THIS_DAY = 'on_this_day', + OnThisDay = 'on_this_day', } export enum Permission { - ALL = 'all', + All = 'all', - ACTIVITY_CREATE = 'activity.create', - ACTIVITY_READ = 'activity.read', - ACTIVITY_UPDATE = 'activity.update', - ACTIVITY_DELETE = 'activity.delete', - ACTIVITY_STATISTICS = 'activity.statistics', + ActivityCreate = 'activity.create', + ActivityRead = 'activity.read', + ActivityUpdate = 'activity.update', + ActivityDelete = 'activity.delete', + ActivityStatistics = 'activity.statistics', - API_KEY_CREATE = 'apiKey.create', - API_KEY_READ = 'apiKey.read', - API_KEY_UPDATE = 'apiKey.update', - API_KEY_DELETE = 'apiKey.delete', + ApiKeyCreate = 'apiKey.create', + ApiKeyRead = 'apiKey.read', + ApiKeyUpdate = 'apiKey.update', + ApiKeyDelete = 'apiKey.delete', // ASSET_CREATE = 'asset.create', - ASSET_READ = 'asset.read', - ASSET_UPDATE = 'asset.update', - ASSET_DELETE = 'asset.delete', - ASSET_SHARE = 'asset.share', - ASSET_VIEW = 'asset.view', - ASSET_DOWNLOAD = 'asset.download', - ASSET_UPLOAD = 'asset.upload', + AssetRead = 'asset.read', + AssetUpdate = 'asset.update', + AssetDelete = 'asset.delete', + AssetShare = 'asset.share', + AssetView = 'asset.view', + AssetDownload = 'asset.download', + AssetUpload = 'asset.upload', - ALBUM_CREATE = 'album.create', - ALBUM_READ = 'album.read', - ALBUM_UPDATE = 'album.update', - ALBUM_DELETE = 'album.delete', - ALBUM_STATISTICS = 'album.statistics', + AlbumCreate = 'album.create', + AlbumRead = 'album.read', + AlbumUpdate = 'album.update', + AlbumDelete = 'album.delete', + AlbumStatistics = 'album.statistics', - ALBUM_ADD_ASSET = 'album.addAsset', - ALBUM_REMOVE_ASSET = 'album.removeAsset', - ALBUM_SHARE = 'album.share', - ALBUM_DOWNLOAD = 'album.download', + AlbumAddAsset = 'album.addAsset', + AlbumRemoveAsset = 'album.removeAsset', + AlbumShare = 'album.share', + AlbumDownload = 'album.download', - AUTH_DEVICE_DELETE = 'authDevice.delete', + AuthDeviceDelete = 'authDevice.delete', - ARCHIVE_READ = 'archive.read', + ArchiveRead = 'archive.read', - FACE_CREATE = 'face.create', - FACE_READ = 'face.read', - FACE_UPDATE = 'face.update', - FACE_DELETE = 'face.delete', + FaceCreate = 'face.create', + FaceRead = 'face.read', + FaceUpdate = 'face.update', + FaceDelete = 'face.delete', - LIBRARY_CREATE = 'library.create', - LIBRARY_READ = 'library.read', - LIBRARY_UPDATE = 'library.update', - LIBRARY_DELETE = 'library.delete', - LIBRARY_STATISTICS = 'library.statistics', + LibraryCreate = 'library.create', + LibraryRead = 'library.read', + LibraryUpdate = 'library.update', + LibraryDelete = 'library.delete', + LibraryStatistics = 'library.statistics', - TIMELINE_READ = 'timeline.read', - TIMELINE_DOWNLOAD = 'timeline.download', + TimelineRead = 'timeline.read', + TimelineDownload = 'timeline.download', - MEMORY_CREATE = 'memory.create', - MEMORY_READ = 'memory.read', - MEMORY_UPDATE = 'memory.update', - MEMORY_DELETE = 'memory.delete', + MemoryCreate = 'memory.create', + MemoryRead = 'memory.read', + MemoryUpdate = 'memory.update', + MemoryDelete = 'memory.delete', - NOTIFICATION_CREATE = 'notification.create', - NOTIFICATION_READ = 'notification.read', - NOTIFICATION_UPDATE = 'notification.update', - NOTIFICATION_DELETE = 'notification.delete', + NotificationCreate = 'notification.create', + NotificationRead = 'notification.read', + NotificationUpdate = 'notification.update', + NotificationDelete = 'notification.delete', - PARTNER_CREATE = 'partner.create', - PARTNER_READ = 'partner.read', - PARTNER_UPDATE = 'partner.update', - PARTNER_DELETE = 'partner.delete', + PartnerCreate = 'partner.create', + PartnerRead = 'partner.read', + PartnerUpdate = 'partner.update', + PartnerDelete = 'partner.delete', - PERSON_CREATE = 'person.create', - PERSON_READ = 'person.read', - PERSON_UPDATE = 'person.update', - PERSON_DELETE = 'person.delete', - PERSON_STATISTICS = 'person.statistics', - PERSON_MERGE = 'person.merge', - PERSON_REASSIGN = 'person.reassign', + PersonCreate = 'person.create', + PersonRead = 'person.read', + PersonUpdate = 'person.update', + PersonDelete = 'person.delete', + PersonStatistics = 'person.statistics', + PersonMerge = 'person.merge', + PersonReassign = 'person.reassign', - SESSION_CREATE = 'session.create', - SESSION_READ = 'session.read', - SESSION_UPDATE = 'session.update', - SESSION_DELETE = 'session.delete', - SESSION_LOCK = 'session.lock', + SessionCreate = 'session.create', + SessionRead = 'session.read', + SessionUpdate = 'session.update', + SessionDelete = 'session.delete', + SessionLock = 'session.lock', - SHARED_LINK_CREATE = 'sharedLink.create', - SHARED_LINK_READ = 'sharedLink.read', - SHARED_LINK_UPDATE = 'sharedLink.update', - SHARED_LINK_DELETE = 'sharedLink.delete', + SharedLinkCreate = 'sharedLink.create', + SharedLinkRead = 'sharedLink.read', + SharedLinkUpdate = 'sharedLink.update', + SharedLinkDelete = 'sharedLink.delete', - STACK_CREATE = 'stack.create', - STACK_READ = 'stack.read', - STACK_UPDATE = 'stack.update', - STACK_DELETE = 'stack.delete', + StackCreate = 'stack.create', + StackRead = 'stack.read', + StackUpdate = 'stack.update', + StackDelete = 'stack.delete', - SYSTEM_CONFIG_READ = 'systemConfig.read', - SYSTEM_CONFIG_UPDATE = 'systemConfig.update', + SystemConfigRead = 'systemConfig.read', + SystemConfigUpdate = 'systemConfig.update', - SYSTEM_METADATA_READ = 'systemMetadata.read', - SYSTEM_METADATA_UPDATE = 'systemMetadata.update', + SystemMetadataRead = 'systemMetadata.read', + SystemMetadataUpdate = 'systemMetadata.update', - TAG_CREATE = 'tag.create', - TAG_READ = 'tag.read', - TAG_UPDATE = 'tag.update', - TAG_DELETE = 'tag.delete', - TAG_ASSET = 'tag.asset', + TagCreate = 'tag.create', + TagRead = 'tag.read', + TagUpdate = 'tag.update', + TagDelete = 'tag.delete', + TagAsset = 'tag.asset', - ADMIN_USER_CREATE = 'admin.user.create', - ADMIN_USER_READ = 'admin.user.read', - ADMIN_USER_UPDATE = 'admin.user.update', - ADMIN_USER_DELETE = 'admin.user.delete', + AdminUserCreate = 'admin.user.create', + AdminUserRead = 'admin.user.read', + AdminUserUpdate = 'admin.user.update', + AdminUserDelete = 'admin.user.delete', } export enum SharedLinkType { - ALBUM = 'ALBUM', + Album = 'ALBUM', /** * Individual asset * or group of assets that are not in an album */ - INDIVIDUAL = 'INDIVIDUAL', + Individual = 'INDIVIDUAL', } export enum StorageFolder { - ENCODED_VIDEO = 'encoded-video', - LIBRARY = 'library', - UPLOAD = 'upload', - PROFILE = 'profile', - THUMBNAILS = 'thumbs', - BACKUPS = 'backups', + EncodedVideo = 'encoded-video', + Library = 'library', + Upload = 'upload', + Profile = 'profile', + Thumbnails = 'thumbs', + Backups = 'backups', } export enum SystemMetadataKey { - REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', - FACIAL_RECOGNITION_STATE = 'facial-recognition-state', - MEMORIES_STATE = 'memories-state', - ADMIN_ONBOARDING = 'admin-onboarding', - SYSTEM_CONFIG = 'system-config', - SYSTEM_FLAGS = 'system-flags', - VERSION_CHECK_STATE = 'version-check-state', - LICENSE = 'license', + ReverseGeocodingState = 'reverse-geocoding-state', + FacialRecognitionState = 'facial-recognition-state', + MemoriesState = 'memories-state', + AdminOnboarding = 'admin-onboarding', + SystemConfig = 'system-config', + SystemFlags = 'system-flags', + VersionCheckState = 'version-check-state', + License = 'license', } export enum UserMetadataKey { - PREFERENCES = 'preferences', - LICENSE = 'license', - ONBOARDING = 'onboarding', + Preferences = 'preferences', + License = 'license', + Onboarding = 'onboarding', } export enum UserAvatarColor { - PRIMARY = 'primary', - PINK = 'pink', - RED = 'red', - YELLOW = 'yellow', - BLUE = 'blue', - GREEN = 'green', - PURPLE = 'purple', - ORANGE = 'orange', - GRAY = 'gray', - AMBER = 'amber', + Primary = 'primary', + Pink = 'pink', + Red = 'red', + Yellow = 'yellow', + Blue = 'blue', + Green = 'green', + Purple = 'purple', + Orange = 'orange', + Gray = 'gray', + Amber = 'amber', } export enum UserStatus { - ACTIVE = 'active', - REMOVING = 'removing', - DELETED = 'deleted', + Active = 'active', + Removing = 'removing', + Deleted = 'deleted', } export enum AssetStatus { - ACTIVE = 'active', - TRASHED = 'trashed', - DELETED = 'deleted', + Active = 'active', + Trashed = 'trashed', + Deleted = 'deleted', } export enum SourceType { - MACHINE_LEARNING = 'machine-learning', - EXIF = 'exif', - MANUAL = 'manual', + MachineLearning = 'machine-learning', + Exif = 'exif', + Manual = 'manual', } export enum ManualJobName { - PERSON_CLEANUP = 'person-cleanup', - TAG_CLEANUP = 'tag-cleanup', - USER_CLEANUP = 'user-cleanup', - MEMORY_CLEANUP = 'memory-cleanup', - MEMORY_CREATE = 'memory-create', - BACKUP_DATABASE = 'backup-database', + PersonCleanup = 'person-cleanup', + TagCleanup = 'tag-cleanup', + UserCleanup = 'user-cleanup', + MemoryCleanup = 'memory-cleanup', + MemoryCreate = 'memory-create', + BackupDatabase = 'backup-database', } export enum AssetPathType { - ORIGINAL = 'original', - FULLSIZE = 'fullsize', - PREVIEW = 'preview', - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded_video', - SIDECAR = 'sidecar', + Original = 'original', + FullSize = 'fullsize', + Preview = 'preview', + Thumbnail = 'thumbnail', + EncodedVideo = 'encoded_video', + Sidecar = 'sidecar', } export enum PersonPathType { - FACE = 'face', + Face = 'face', } export enum UserPathType { - PROFILE = 'profile', + Profile = 'profile', } export type PathType = AssetPathType | PersonPathType | UserPathType; export enum TranscodePolicy { - ALL = 'all', - OPTIMAL = 'optimal', - BITRATE = 'bitrate', - REQUIRED = 'required', - DISABLED = 'disabled', + All = 'all', + Optimal = 'optimal', + Bitrate = 'bitrate', + Required = 'required', + Disabled = 'disabled', } export enum TranscodeTarget { - NONE, - AUDIO, - VIDEO, - ALL, + None = 'NONE', + Audio = 'AUDIO', + Video = 'VIDEO', + All = 'ALL', } export enum VideoCodec { H264 = 'h264', - HEVC = 'hevc', - VP9 = 'vp9', - AV1 = 'av1', + Hevc = 'hevc', + Vp9 = 'vp9', + Av1 = 'av1', } export enum AudioCodec { - MP3 = 'mp3', - AAC = 'aac', - LIBOPUS = 'libopus', - PCMS16LE = 'pcm_s16le', + Mp3 = 'mp3', + Aac = 'aac', + LibOpus = 'libopus', + PcmS16le = 'pcm_s16le', } export enum VideoContainer { - MOV = 'mov', - MP4 = 'mp4', - OGG = 'ogg', - WEBM = 'webm', + Mov = 'mov', + Mp4 = 'mp4', + Ogg = 'ogg', + Webm = 'webm', } -export enum TranscodeHWAccel { - NVENC = 'nvenc', - QSV = 'qsv', - VAAPI = 'vaapi', - RKMPP = 'rkmpp', - DISABLED = 'disabled', +export enum TranscodeHardwareAcceleration { + Nvenc = 'nvenc', + Qsv = 'qsv', + Vaapi = 'vaapi', + Rkmpp = 'rkmpp', + Disabled = 'disabled', } export enum ToneMapping { - HABLE = 'hable', - MOBIUS = 'mobius', - REINHARD = 'reinhard', - DISABLED = 'disabled', + Hable = 'hable', + Mobius = 'mobius', + Reinhard = 'reinhard', + Disabled = 'disabled', } export enum CQMode { - AUTO = 'auto', - CQP = 'cqp', - ICQ = 'icq', + Auto = 'auto', + Cqp = 'cqp', + Icq = 'icq', } export enum Colorspace { - SRGB = 'srgb', + Srgb = 'srgb', P3 = 'p3', } export enum ImageFormat { - JPEG = 'jpeg', - WEBP = 'webp', + Jpeg = 'jpeg', + Webp = 'webp', } export enum RawExtractedFormat { - JPEG = 'jpeg', - JXL = 'jxl', + Jpeg = 'jpeg', + Jxl = 'jxl', } export enum LogLevel { - VERBOSE = 'verbose', - DEBUG = 'debug', - LOG = 'log', - WARN = 'warn', - ERROR = 'error', - FATAL = 'fatal', + Verbose = 'verbose', + Debug = 'debug', + Log = 'log', + Warn = 'warn', + Error = 'error', + Fatal = 'fatal', } export enum MetadataKey { - AUTH_ROUTE = 'auth_route', - ADMIN_ROUTE = 'admin_route', - SHARED_ROUTE = 'shared_route', - API_KEY_SECURITY = 'api_key', - EVENT_CONFIG = 'event_config', - JOB_CONFIG = 'job_config', - TELEMETRY_ENABLED = 'telemetry_enabled', + AuthRoute = 'auth_route', + AdminRoute = 'admin_route', + SharedRoute = 'shared_route', + ApiKeySecurity = 'api_key', + EventConfig = 'event_config', + JobConfig = 'job_config', + TelemetryEnabled = 'telemetry_enabled', } export enum RouteKey { - ASSET = 'assets', - USER = 'users', + Asset = 'assets', + User = 'users', } export enum CacheControl { - PRIVATE_WITH_CACHE = 'private_with_cache', - PRIVATE_WITHOUT_CACHE = 'private_without_cache', - NONE = 'none', -} - -export enum PaginationMode { - LIMIT_OFFSET = 'limit-offset', - SKIP_TAKE = 'skip-take', + PrivateWithCache = 'private_with_cache', + PrivateWithoutCache = 'private_without_cache', + None = 'none', } export enum ImmichEnvironment { - DEVELOPMENT = 'development', - TESTING = 'testing', - PRODUCTION = 'production', + Development = 'development', + Testing = 'testing', + Production = 'production', } export enum ImmichWorker { - API = 'api', - MICROSERVICES = 'microservices', + Api = 'api', + Microservices = 'microservices', } export enum ImmichTelemetry { - HOST = 'host', - API = 'api', - IO = 'io', - REPO = 'repo', - JOB = 'job', + Host = 'host', + Api = 'api', + Io = 'io', + Repo = 'repo', + Job = 'job', } export enum ExifOrientation { @@ -411,11 +406,11 @@ export enum ExifOrientation { } export enum DatabaseExtension { - CUBE = 'cube', - EARTH_DISTANCE = 'earthdistance', - VECTOR = 'vector', - VECTORS = 'vectors', - VECTORCHORD = 'vchord', + Cube = 'cube', + EarthDistance = 'earthdistance', + Vector = 'vector', + Vectors = 'vectors', + VectorChord = 'vchord', } export enum BootstrapEventPriority { @@ -428,135 +423,135 @@ export enum BootstrapEventPriority { } export enum QueueName { - THUMBNAIL_GENERATION = 'thumbnailGeneration', - METADATA_EXTRACTION = 'metadataExtraction', - VIDEO_CONVERSION = 'videoConversion', - FACE_DETECTION = 'faceDetection', - FACIAL_RECOGNITION = 'facialRecognition', - SMART_SEARCH = 'smartSearch', - DUPLICATE_DETECTION = 'duplicateDetection', - BACKGROUND_TASK = 'backgroundTask', - STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration', - MIGRATION = 'migration', - SEARCH = 'search', - SIDECAR = 'sidecar', - LIBRARY = 'library', - NOTIFICATION = 'notifications', - BACKUP_DATABASE = 'backupDatabase', + ThumbnailGeneration = 'thumbnailGeneration', + MetadataExtraction = 'metadataExtraction', + VideoConversion = 'videoConversion', + FaceDetection = 'faceDetection', + FacialRecognition = 'facialRecognition', + SmartSearch = 'smartSearch', + DuplicateDetection = 'duplicateDetection', + BackgroundTask = 'backgroundTask', + StorageTemplateMigration = 'storageTemplateMigration', + Migration = 'migration', + Search = 'search', + Sidecar = 'sidecar', + Library = 'library', + Notification = 'notifications', + BackupDatabase = 'backupDatabase', } export enum JobName { //backups - BACKUP_DATABASE = 'database-backup', + BackupDatabase = 'database-backup', // conversion - QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', - VIDEO_CONVERSION = 'video-conversion', + QueueVideoConversion = 'queue-video-conversion', + VideoConversation = 'video-conversion', // thumbnails - QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', - GENERATE_THUMBNAILS = 'generate-thumbnails', - GENERATE_PERSON_THUMBNAIL = 'generate-person-thumbnail', + QueueGenerateThumbnails = 'queue-generate-thumbnails', + GenerateThumbnails = 'generate-thumbnails', + GeneratePersonThumbnail = 'generate-person-thumbnail', // metadata - QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', - METADATA_EXTRACTION = 'metadata-extraction', + QueueMetadataExtraction = 'queue-metadata-extraction', + MetadataExtraction = 'metadata-extraction', // user - USER_DELETION = 'user-deletion', - USER_DELETE_CHECK = 'user-delete-check', - USER_SYNC_USAGE = 'user-sync-usage', + UserDeletion = 'user-deletion', + UserDeleteCheck = 'user-delete-check', + userSyncUsage = 'user-sync-usage', // asset - ASSET_DELETION = 'asset-deletion', - ASSET_DELETION_CHECK = 'asset-deletion-check', + AssetDeletion = 'asset-deletion', + AssetDeletionCheck = 'asset-deletion-check', // storage template - STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration', - STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single', + StorageTemplateMigration = 'storage-template-migration', + StorageTemplateMigrationSingle = 'storage-template-migration-single', // tags - TAG_CLEANUP = 'tag-cleanup', + TagCleanup = 'tag-cleanup', // migration - QUEUE_MIGRATION = 'queue-migration', - MIGRATE_ASSET = 'migrate-asset', - MIGRATE_PERSON = 'migrate-person', + QueueMigration = 'queue-migration', + MigrateAsset = 'migrate-asset', + MigratePerson = 'migrate-person', // facial recognition - PERSON_CLEANUP = 'person-cleanup', - QUEUE_FACE_DETECTION = 'queue-face-detection', - FACE_DETECTION = 'face-detection', - QUEUE_FACIAL_RECOGNITION = 'queue-facial-recognition', - FACIAL_RECOGNITION = 'facial-recognition', + PersonCleanup = 'person-cleanup', + QueueFaceDetection = 'queue-face-detection', + FaceDetection = 'face-detection', + QueueFacialRecognition = 'queue-facial-recognition', + FacialRecognition = 'facial-recognition', // library management - LIBRARY_QUEUE_SYNC_FILES = 'library-queue-sync-files', - LIBRARY_QUEUE_SYNC_ASSETS = 'library-queue-sync-assets', - LIBRARY_SYNC_FILES = 'library-sync-files', - LIBRARY_SYNC_ASSETS = 'library-sync-assets', - LIBRARY_ASSET_REMOVAL = 'handle-library-file-deletion', - LIBRARY_DELETE = 'library-delete', - LIBRARY_QUEUE_SCAN_ALL = 'library-queue-scan-all', - LIBRARY_QUEUE_CLEANUP = 'library-queue-cleanup', + LibraryQueueSyncFiles = 'library-queue-sync-files', + LibraryQueueSyncAssets = 'library-queue-sync-assets', + LibrarySyncFiles = 'library-sync-files', + LibrarySyncAssets = 'library-sync-assets', + LibraryAssetRemoval = 'handle-library-file-deletion', + LibraryDelete = 'library-delete', + LibraryQueueScanAll = 'library-queue-scan-all', + LibraryQueueCleanup = 'library-queue-cleanup', // cleanup - DELETE_FILES = 'delete-files', - CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', - CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', + DeleteFiles = 'delete-files', + CleanOldAuditLogs = 'clean-old-audit-logs', + CleanOldSessionTokens = 'clean-old-session-tokens', // memories - MEMORIES_CLEANUP = 'memories-cleanup', - MEMORIES_CREATE = 'memories-create', + MemoriesCleanup = 'memories-cleanup', + MemoriesCreate = 'memories-create', // smart search - QUEUE_SMART_SEARCH = 'queue-smart-search', - SMART_SEARCH = 'smart-search', + QueueSmartSearch = 'queue-smart-search', + SmartSearch = 'smart-search', - QUEUE_TRASH_EMPTY = 'queue-trash-empty', + QueueTrashEmpty = 'queue-trash-empty', // duplicate detection - QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection', - DUPLICATE_DETECTION = 'duplicate-detection', + QueueDuplicateDetection = 'queue-duplicate-detection', + DuplicateDetection = 'duplicate-detection', // XMP sidecars - QUEUE_SIDECAR = 'queue-sidecar', - SIDECAR_DISCOVERY = 'sidecar-discovery', - SIDECAR_SYNC = 'sidecar-sync', - SIDECAR_WRITE = 'sidecar-write', + QueueSidecar = 'queue-sidecar', + SidecarDiscovery = 'sidecar-discovery', + SidecarSync = 'sidecar-sync', + SidecarWrite = 'sidecar-write', // Notification - NOTIFY_SIGNUP = 'notify-signup', - NOTIFY_ALBUM_INVITE = 'notify-album-invite', - NOTIFY_ALBUM_UPDATE = 'notify-album-update', - NOTIFICATIONS_CLEANUP = 'notifications-cleanup', - SEND_EMAIL = 'notification-send-email', + NotifySignup = 'notify-signup', + NotifyAlbumInvite = 'notify-album-invite', + NotifyAlbumUpdate = 'notify-album-update', + NotificationsCleanup = 'notifications-cleanup', + SendMail = 'notification-send-email', // Version check - VERSION_CHECK = 'version-check', + VersionCheck = 'version-check', } export enum JobCommand { - START = 'start', - PAUSE = 'pause', - RESUME = 'resume', - EMPTY = 'empty', - CLEAR_FAILED = 'clear-failed', + Start = 'start', + Pause = 'pause', + Resume = 'resume', + Empty = 'empty', + ClearFailed = 'clear-failed', } export enum JobStatus { - SUCCESS = 'success', - FAILED = 'failed', - SKIPPED = 'skipped', + Success = 'success', + Failed = 'failed', + Skipped = 'skipped', } export enum QueueCleanType { - FAILED = 'failed', + Failed = 'failed', } export enum VectorIndex { - CLIP = 'clip_index', - FACE = 'face_index', + Clip = 'clip_index', + Face = 'face_index', } export enum DatabaseLock { @@ -663,8 +658,8 @@ export enum NotificationType { } export enum OAuthTokenEndpointAuthMethod { - CLIENT_SECRET_POST = 'client_secret_post', - CLIENT_SECRET_BASIC = 'client_secret_basic', + ClientSecretPost = 'client_secret_post', + ClientSecretBasic = 'client_secret_basic', } export enum DatabaseSslMode { @@ -676,14 +671,14 @@ export enum DatabaseSslMode { } export enum AssetVisibility { - ARCHIVE = 'archive', - TIMELINE = 'timeline', + Archive = 'archive', + Timeline = 'timeline', /** * Video part of the LivePhotos and MotionPhotos */ - HIDDEN = 'hidden', - LOCKED = 'locked', + Hidden = 'hidden', + Locked = 'locked', } export enum CronJob { diff --git a/server/src/main.ts b/server/src/main.ts index 95b35c6915..591fc156d9 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -20,7 +20,7 @@ const onExit = (name: string, exitCode: number | null) => { if (exitCode !== 0) { console.error(`${name} worker exited with code ${exitCode}`); - if (apiProcess && name !== ImmichWorker.API) { + if (apiProcess && name !== ImmichWorker.Api) { console.error('Killing api process'); apiProcess.kill('SIGTERM'); apiProcess = undefined; @@ -34,7 +34,7 @@ function bootstrapWorker(name: ImmichWorker) { console.log(`Starting ${name} worker`); let worker: Worker | ChildProcess; - if (name === ImmichWorker.API) { + if (name === ImmichWorker.Api) { worker = fork(`./dist/workers/${name}.js`, [], { execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)), }); @@ -50,7 +50,7 @@ function bootstrapWorker(name: ImmichWorker) { function bootstrap() { if (immichApp === 'immich-admin') { process.title = 'immich_admin_cli'; - process.env.IMMICH_LOG_LEVEL = LogLevel.WARN; + process.env.IMMICH_LOG_LEVEL = LogLevel.Warn; return CommandFactory.run(ImmichAdminModule); } diff --git a/server/src/middleware/asset-upload.interceptor.ts b/server/src/middleware/asset-upload.interceptor.ts index bc403ee562..0f1eaa4ce5 100644 --- a/server/src/middleware/asset-upload.interceptor.ts +++ b/server/src/middleware/asset-upload.interceptor.ts @@ -15,7 +15,7 @@ export class AssetUploadInterceptor implements NestInterceptor { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse>(); - const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]); + const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]); const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum); if (response) { res.status(200); diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 438843436b..238f99257a 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -23,12 +23,12 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator = const decorators: MethodDecorator[] = [ ApiBearerAuth(), ApiCookieAuth(), - ApiSecurity(MetadataKey.API_KEY_SECURITY), - SetMetadata(MetadataKey.AUTH_ROUTE, options || {}), + ApiSecurity(MetadataKey.ApiKeySecurity), + SetMetadata(MetadataKey.AuthRoute, options || {}), ]; if ((options as SharedLinkRoute)?.sharedLink) { - decorators.push(ApiQuery({ name: ImmichQuery.SHARED_LINK_KEY, type: String, required: false })); + decorators.push(ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false })); } return applyDecorators(...decorators); @@ -76,7 +76,7 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(MetadataKey.AUTH_ROUTE, targets); + const options = this.reflector.getAllAndOverride(MetadataKey.AuthRoute, targets); if (!options) { return true; } diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index b6f37dbbd2..59c28849e1 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -154,11 +154,11 @@ export class FileUploadInterceptor implements NestInterceptor { private getHandler(route: RouteKey) { switch (route) { - case RouteKey.ASSET: { + case RouteKey.Asset: { return this.handlers.assetUpload; } - case RouteKey.USER: { + case RouteKey.User: { return this.handlers.userProfile; } diff --git a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts index 68e1618775..2bd1acad34 100644 --- a/server/src/migrations/1718486162779-AddFaceSearchRelation.ts +++ b/server/src/migrations/1718486162779-AddFaceSearchRelation.ts @@ -6,7 +6,7 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddFaceSearchRelation1718486162779 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { const vectorExtension = await getVectorExtension(queryRunner); - if (vectorExtension === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.Vectors) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); } @@ -52,7 +52,7 @@ export class AddFaceSearchRelation1718486162779 implements MigrationInterface { public async down(queryRunner: QueryRunner): Promise { const vectorExtension = await getVectorExtension(queryRunner); - if (vectorExtension === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.Vectors) { await queryRunner.query(`SET search_path TO "$user", public, vectors`); } diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 14a765778e..5cceb6dbe0 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -91,7 +91,7 @@ class AlbumAccess { } const accessRole = - access === AlbumUserRole.EDITOR ? [AlbumUserRole.EDITOR] : [AlbumUserRole.EDITOR, AlbumUserRole.VIEWER]; + access === AlbumUserRole.Editor ? [AlbumUserRole.Editor] : [AlbumUserRole.Editor, AlbumUserRole.Viewer]; return this.db .selectFrom('album') @@ -178,7 +178,7 @@ class AssetAccess { .select('asset.id') .where('asset.id', 'in', [...assetIds]) .where('asset.ownerId', '=', userId) - .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.LOCKED)) + .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.Locked)) .execute() .then((assets) => new Set(assets.map((asset) => asset.id))); } @@ -200,8 +200,8 @@ class AssetAccess { .where('partner.sharedWithId', '=', userId) .where((eb) => eb.or([ - eb('asset.visibility', '=', sql.lit(AssetVisibility.TIMELINE)), - eb('asset.visibility', '=', sql.lit(AssetVisibility.HIDDEN)), + eb('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)), + eb('asset.visibility', '=', sql.lit(AssetVisibility.Hidden)), ]), ) diff --git a/server/src/repositories/activity.repository.ts b/server/src/repositories/activity.repository.ts index 9b991ef17f..1a1104b118 100644 --- a/server/src/repositories/activity.repository.ts +++ b/server/src/repositories/activity.repository.ts @@ -90,7 +90,7 @@ export class ActivityRepository { .where('activity.albumId', '=', albumId) .where(({ or, and, eb }) => or([ - and([eb('asset.deletedAt', 'is', null), eb('asset.visibility', '!=', sql.lit(AssetVisibility.LOCKED))]), + and([eb('asset.deletedAt', 'is', null), eb('asset.visibility', '!=', sql.lit(AssetVisibility.Locked))]), eb('asset.id', 'is', null), ]), ) diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index d968ed100c..2fce797aff 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -24,7 +24,7 @@ export class AlbumUserRepository { .executeTakeFirstOrThrow(); } - @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] }) + @GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.Viewer }] }) update({ usersId, albumsId }: AlbumPermissionId, dto: Updateable) { return this.db .updateTable('album_user') diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index c784ae276f..0500bb867f 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -62,7 +62,7 @@ export class AssetJobRepository { .select(['asset.id', 'asset.thumbhash']) .select(withFiles) .where('asset.deletedAt', 'is', null) - .where('asset.visibility', '!=', AssetVisibility.HIDDEN) + .where('asset.visibility', '!=', AssetVisibility.Hidden) .$if(!force, (qb) => qb // If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails @@ -117,7 +117,7 @@ export class AssetJobRepository { .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID, AssetFileType.THUMBNAIL] }) + @GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] }) getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) { return this.db .selectFrom('asset_file') @@ -130,7 +130,7 @@ export class AssetJobRepository { private assetsWithPreviews() { return this.db .selectFrom('asset') - .where('asset.visibility', '!=', AssetVisibility.HIDDEN) + .where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.deletedAt', 'is', null) .innerJoin('asset_job_status as job_status', 'assetId', 'asset.id') .where('job_status.previewAt', 'is not', null); @@ -167,7 +167,7 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id', 'asset.visibility']) - .select((eb) => withFiles(eb, AssetFileType.PREVIEW)) + .select((eb) => withFiles(eb, AssetFileType.Preview)) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -179,7 +179,7 @@ export class AssetJobRepository { .select(['asset.id', 'asset.visibility']) .$call(withExifInner) .select((eb) => withFaces(eb, true)) - .select((eb) => withFiles(eb, AssetFileType.PREVIEW)) + .select((eb) => withFiles(eb, AssetFileType.Preview)) .where('asset.id', '=', id) .executeTakeFirst(); } @@ -225,7 +225,7 @@ export class AssetJobRepository { .select(['stack.id', 'stack.primaryAssetId']) .select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets')) .where('stacked.deletedAt', 'is not', null) - .where('stacked.visibility', '=', AssetVisibility.TIMELINE) + .where('stacked.visibility', '=', AssetVisibility.Timeline) .whereRef('stacked.stackId', '=', 'stack.id') .groupBy('stack.id') .as('stacked_assets'), @@ -241,11 +241,11 @@ export class AssetJobRepository { return this.db .selectFrom('asset') .select(['asset.id']) - .where('asset.type', '=', AssetType.VIDEO) + .where('asset.type', '=', AssetType.Video) .$if(!force, (qb) => qb .where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')])) - .where('asset.visibility', '!=', AssetVisibility.HIDDEN), + .where('asset.visibility', '!=', AssetVisibility.Hidden), ) .where('asset.deletedAt', 'is', null) .stream(); @@ -257,7 +257,7 @@ export class AssetJobRepository { .selectFrom('asset') .select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath']) .where('asset.id', '=', id) - .where('asset.type', '=', AssetType.VIDEO) + .where('asset.type', '=', AssetType.Video) .executeTakeFirst(); } @@ -327,7 +327,7 @@ export class AssetJobRepository { .$if(!force, (qb) => qb.where((eb) => eb.or([eb('asset.sidecarPath', '=', ''), eb('asset.sidecarPath', 'is', null)])), ) - .where('asset.visibility', '!=', AssetVisibility.HIDDEN) + .where('asset.visibility', '!=', AssetVisibility.Hidden) .stream(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index f00d0c170f..cb7e804f6f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -230,13 +230,13 @@ export class AssetRepository { .where('asset_job_status.previewAt', 'is not', null) .where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`) .where('asset.ownerId', '=', anyUuid(ownerIds)) - .where('asset.visibility', '=', AssetVisibility.TIMELINE) + .where('asset.visibility', '=', AssetVisibility.Timeline) .where((eb) => eb.exists((qb) => qb .selectFrom('asset_file') .whereRef('assetId', '=', 'asset.id') - .where('asset_file.type', '=', AssetFileType.PREVIEW), + .where('asset_file.type', '=', AssetFileType.Preview), ), ) .where('asset.deletedAt', 'is', null) @@ -318,7 +318,7 @@ export class AssetRepository { .select(['deviceAssetId']) .where('ownerId', '=', asUuid(ownerId)) .where('deviceId', '=', deviceId) - .where('visibility', '!=', AssetVisibility.HIDDEN) + .where('visibility', '!=', AssetVisibility.Hidden) .where('deletedAt', 'is', null) .execute(); @@ -363,7 +363,7 @@ export class AssetRepository { .whereRef('stacked.stackId', '=', 'stack.id') .whereRef('stacked.id', '!=', 'stack.primaryAssetId') .where('stacked.deletedAt', 'is', null) - .where('stacked.visibility', '=', AssetVisibility.TIMELINE) + .where('stacked.visibility', '=', AssetVisibility.Timeline) .groupBy('stack.id') .as('stacked_assets'), (join) => join.on('stack.id', 'is not', null), @@ -463,15 +463,15 @@ export class AssetRepository { getStatistics(ownerId: string, { visibility, isFavorite, isTrashed }: AssetStatsOptions): Promise { return this.db .selectFrom('asset') - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO)) - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.IMAGE).as(AssetType.IMAGE)) - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO)) - .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.Audio).as(AssetType.Audio)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.Image).as(AssetType.Image)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.Video).as(AssetType.Video)) + .select((eb) => eb.fn.countAll().filterWhere('type', '=', AssetType.Other).as(AssetType.Other)) .where('ownerId', '=', asUuid(ownerId)) .$if(visibility === undefined, withDefaultVisibility) .$if(!!visibility, (qb) => qb.where('asset.visibility', '=', visibility!)) .$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!)) - .$if(!!isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.DELETED)) + .$if(!!isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .where('deletedAt', isTrashed ? 'is not' : 'is', null) .executeTakeFirstOrThrow(); } @@ -496,7 +496,7 @@ export class AssetRepository { qb .selectFrom('asset') .select(truncatedDate().as('timeBucket')) - .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.DELETED)) + .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) @@ -606,7 +606,7 @@ export class AssetRepository { .select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack')) .whereRef('stacked.stackId', '=', 'asset.stackId') .where('stacked.deletedAt', 'is', null) - .where('stacked.visibility', '=', AssetVisibility.TIMELINE) + .where('stacked.visibility', '=', AssetVisibility.Timeline) .groupBy('stacked.stackId') .as('stacked_assets'), (join) => join.onTrue(), @@ -617,7 +617,7 @@ export class AssetRepository { .$if(options.isDuplicate !== undefined, (qb) => qb.where('asset.duplicateId', options.isDuplicate ? 'is not' : 'is', null), ) - .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.DELETED)) + .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), ) @@ -671,8 +671,8 @@ export class AssetRepository { .select(['assetId as data', 'asset_exif.city as value']) .$narrowType<{ value: NotNull }>() .where('ownerId', '=', asUuid(ownerId)) - .where('visibility', '=', AssetVisibility.TIMELINE) - .where('type', '=', AssetType.IMAGE) + .where('visibility', '=', AssetVisibility.Timeline) + .where('type', '=', AssetType.Image) .where('deletedAt', 'is', null) .limit(maxFields) .execute(); @@ -710,7 +710,7 @@ export class AssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo().as('stack')) .where('asset.ownerId', '=', asUuid(ownerId)) - .where('asset.visibility', '!=', AssetVisibility.HIDDEN) + .where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.updatedAt', '<=', updatedUntil) .$if(!!lastId, (qb) => qb.where('asset.id', '>', lastId!)) .orderBy('asset.id') @@ -738,7 +738,7 @@ export class AssetRepository { ) .select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo()).as('stack')) .where('asset.ownerId', '=', anyUuid(options.userIds)) - .where('asset.visibility', '!=', AssetVisibility.HIDDEN) + .where('asset.visibility', '!=', AssetVisibility.Hidden) .where('asset.updatedAt', '>', options.updatedAfter) .limit(options.limit) .execute(); diff --git a/server/src/repositories/audit.repository.ts b/server/src/repositories/audit.repository.ts index 85ca4a79f8..2d56eddc9a 100644 --- a/server/src/repositories/audit.repository.ts +++ b/server/src/repositories/audit.repository.ts @@ -18,7 +18,7 @@ export class AuditRepository { @GenerateSql({ params: [ DummyValue.DATE, - { action: DatabaseAction.CREATE, entityType: EntityType.ASSET, userIds: [DummyValue.UUID] }, + { action: DatabaseAction.Create, entityType: EntityType.Asset, userIds: [DummyValue.UUID] }, ], }) async getAfter(since: Date, options: AuditSearch): Promise { diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index 238b48bcef..a096c6a4bc 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -275,14 +275,14 @@ describe('getEnv', () => { process.env.IMMICH_TELEMETRY_EXCLUDE = 'job'; const { telemetry } = getEnv(); expect(telemetry.metrics).toEqual( - new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO, ImmichTelemetry.REPO]), + new Set([ImmichTelemetry.Api, ImmichTelemetry.Host, ImmichTelemetry.Io, ImmichTelemetry.Repo]), ); }); it('should run with specific telemetry metrics', () => { process.env.IMMICH_TELEMETRY_INCLUDE = 'io, host, api'; const { telemetry } = getEnv(); - expect(telemetry.metrics).toEqual(new Set([ImmichTelemetry.API, ImmichTelemetry.HOST, ImmichTelemetry.IO])); + expect(telemetry.metrics).toEqual(new Set([ImmichTelemetry.Api, ImmichTelemetry.Host, ImmichTelemetry.Io])); }); }); }); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index dbb57bb141..7038338927 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -136,7 +136,7 @@ const getEnv = (): EnvData => { ); } - const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.API, ImmichWorker.MICROSERVICES]); + const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]); const excludedWorkers = asSet(dto.IMMICH_WORKERS_EXCLUDE, []); const workers = [...setDifference(includedWorkers, excludedWorkers)]; for (const worker of workers) { @@ -145,8 +145,8 @@ const getEnv = (): EnvData => { } } - const environment = dto.IMMICH_ENV || ImmichEnvironment.PRODUCTION; - const isProd = environment === ImmichEnvironment.PRODUCTION; + const environment = dto.IMMICH_ENV || ImmichEnvironment.Production; + const isProd = environment === ImmichEnvironment.Production; const buildFolder = dto.IMMICH_BUILD_DATA || '/build'; const folders = { geodata: join(buildFolder, 'geodata'), @@ -199,15 +199,15 @@ const getEnv = (): EnvData => { let vectorExtension: VectorExtension | undefined; switch (dto.DB_VECTOR_EXTENSION) { case 'pgvector': { - vectorExtension = DatabaseExtension.VECTOR; + vectorExtension = DatabaseExtension.Vector; break; } case 'pgvecto.rs': { - vectorExtension = DatabaseExtension.VECTORS; + vectorExtension = DatabaseExtension.Vectors; break; } case 'vectorchord': { - vectorExtension = DatabaseExtension.VECTORCHORD; + vectorExtension = DatabaseExtension.VectorChord; break; } } @@ -254,11 +254,11 @@ const getEnv = (): EnvData => { mount: true, generateId: true, setup: (cls, req: Request, res: Response) => { - const headerValues = req.headers[ImmichHeader.CID]; + const headerValues = req.headers[ImmichHeader.Cid]; const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues; const cid = headerValue || cls.get(CLS_ID); cls.set(CLS_ID, cid); - res.header(ImmichHeader.CID, cid); + res.header(ImmichHeader.Cid, cid); }, }, }, @@ -278,9 +278,9 @@ const getEnv = (): EnvData => { otel: { metrics: { - hostMetrics: telemetries.has(ImmichTelemetry.HOST), + hostMetrics: telemetries.has(ImmichTelemetry.Host), apiMetrics: { - enable: telemetries.has(ImmichTelemetry.API), + enable: telemetries.has(ImmichTelemetry.Api), ignoreRoutes: excludePaths, }, }, @@ -335,7 +335,7 @@ export class ConfigRepository { } isDev() { - return this.getEnv().environment === ImmichEnvironment.DEVELOPMENT; + return this.getEnv().environment === ImmichEnvironment.Development; } getWorker() { diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index b1aefe19f8..8b5c728ce4 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -53,8 +53,8 @@ export async function getVectorExtension(runner: Kysely | QueryRunner): Prom } export const probes: Record = { - [VectorIndex.CLIP]: 1, - [VectorIndex.FACE]: 1, + [VectorIndex.Clip]: 1, + [VectorIndex.Face]: 1, }; @Injectable() @@ -77,7 +77,7 @@ export class DatabaseRepository { return getVectorExtension(this.db); } - @GenerateSql({ params: [[DatabaseExtension.VECTORS]] }) + @GenerateSql({ params: [[DatabaseExtension.Vectors]] }) async getExtensionVersions(extensions: readonly DatabaseExtension[]): Promise { const { rows } = await sql` SELECT name, default_version as "availableVersion", installed_version as "installedVersion" @@ -89,13 +89,13 @@ export class DatabaseRepository { getExtensionVersionRange(extension: VectorExtension): string { switch (extension) { - case DatabaseExtension.VECTORCHORD: { + case DatabaseExtension.VectorChord: { return VECTORCHORD_VERSION_RANGE; } - case DatabaseExtension.VECTORS: { + case DatabaseExtension.Vectors: { return VECTORS_VERSION_RANGE; } - case DatabaseExtension.VECTOR: { + case DatabaseExtension.Vector: { return VECTOR_VERSION_RANGE; } default: { @@ -117,7 +117,7 @@ export class DatabaseRepository { async createExtension(extension: DatabaseExtension): Promise { this.logger.log(`Creating ${EXTENSION_NAMES[extension]} extension`); await sql`CREATE EXTENSION IF NOT EXISTS ${sql.raw(extension)} CASCADE`.execute(this.db); - if (extension === DatabaseExtension.VECTORCHORD) { + if (extension === DatabaseExtension.VectorChord) { const dbName = sql.id(await this.getDatabaseName()); await sql`ALTER DATABASE ${dbName} SET vchordrq.probes = 1`.execute(this.db); await sql`SET vchordrq.probes = 1`.execute(this.db); @@ -147,8 +147,8 @@ export class DatabaseRepository { } await Promise.all([ - this.db.schema.dropIndex(VectorIndex.CLIP).ifExists().execute(), - this.db.schema.dropIndex(VectorIndex.FACE).ifExists().execute(), + this.db.schema.dropIndex(VectorIndex.Clip).ifExists().execute(), + this.db.schema.dropIndex(VectorIndex.Face).ifExists().execute(), ]); await this.db.transaction().execute(async (tx) => { @@ -156,14 +156,14 @@ export class DatabaseRepository { await sql`ALTER EXTENSION ${sql.raw(extension)} UPDATE TO ${sql.lit(targetVersion)}`.execute(tx); - if (extension === DatabaseExtension.VECTORS && (diff === 'major' || diff === 'minor')) { + if (extension === DatabaseExtension.Vectors && (diff === 'major' || diff === 'minor')) { await sql`SELECT pgvectors_upgrade()`.execute(tx); restartRequired = true; } }); if (!restartRequired) { - await Promise.all([this.reindexVectors(VectorIndex.CLIP), this.reindexVectors(VectorIndex.FACE)]); + await Promise.all([this.reindexVectors(VectorIndex.Clip), this.reindexVectors(VectorIndex.Face)]); } return { restartRequired }; @@ -171,7 +171,7 @@ export class DatabaseRepository { async prewarm(index: VectorIndex): Promise { const vectorExtension = await getVectorExtension(this.db); - if (vectorExtension !== DatabaseExtension.VECTORCHORD) { + if (vectorExtension !== DatabaseExtension.VectorChord) { return; } this.logger.debug(`Prewarming ${index}`); @@ -196,19 +196,19 @@ export class DatabaseRepository { } switch (vectorExtension) { - case DatabaseExtension.VECTOR: { + case DatabaseExtension.Vector: { if (!row.indexdef.toLowerCase().includes('using hnsw')) { promises.push(this.reindexVectors(indexName)); } break; } - case DatabaseExtension.VECTORS: { + case DatabaseExtension.Vectors: { if (!row.indexdef.toLowerCase().includes('using vectors')) { promises.push(this.reindexVectors(indexName)); } break; } - case DatabaseExtension.VECTORCHORD: { + case DatabaseExtension.VectorChord: { const matches = row.indexdef.match(/(?<=lists = \[)\d+/g); const lists = matches && matches.length > 0 ? Number(matches[0]) : 1; promises.push( @@ -264,7 +264,7 @@ export class DatabaseRepository { await sql`ALTER TABLE ${sql.raw(table)} ADD COLUMN embedding real[] NOT NULL`.execute(tx); } await sql`ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding SET DATA TYPE real[]`.execute(tx); - const schema = vectorExtension === DatabaseExtension.VECTORS ? 'vectors.' : ''; + const schema = vectorExtension === DatabaseExtension.Vectors ? 'vectors.' : ''; await sql` ALTER TABLE ${sql.raw(table)} ALTER COLUMN embedding @@ -329,11 +329,11 @@ export class DatabaseRepository { .alterColumn('embedding', (col) => col.setDataType(sql.raw(`vector(${dimSize})`))) .execute(); await sql - .raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: VectorIndex.CLIP })) + .raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: VectorIndex.Clip })) .execute(trx); await trx.schema.alterTable('smart_search').dropConstraint('dim_size_constraint').ifExists().execute(); }); - probes[VectorIndex.CLIP] = 1; + probes[VectorIndex.Clip] = 1; await sql`vacuum analyze ${sql.table('smart_search')}`.execute(this.db); } diff --git a/server/src/repositories/download.repository.ts b/server/src/repositories/download.repository.ts index 5645ca1217..ecc1e4d3ab 100644 --- a/server/src/repositories/download.repository.ts +++ b/server/src/repositories/download.repository.ts @@ -34,7 +34,7 @@ export class DownloadRepository { downloadUserId(userId: string) { return builder(this.db) .where('asset.ownerId', '=', userId) - .where('asset.visibility', '!=', AssetVisibility.HIDDEN) + .where('asset.visibility', '!=', AssetVisibility.Hidden) .stream(); } } diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index ac9e5798e5..140c42a643 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -109,14 +109,14 @@ export class DuplicateRepository { assetId: DummyValue.UUID, embedding: DummyValue.VECTOR, maxDistance: 0.6, - type: AssetType.IMAGE, + type: AssetType.Image, userIds: [DummyValue.UUID], }, ], }) search({ assetId, embedding, maxDistance, type, userIds }: DuplicateSearch) { return this.db.transaction().execute(async (trx) => { - await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx); + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx); return await trx .with('cte', (qb) => qb diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 04494d5547..c1b26d5dde 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -166,7 +166,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect continue; } - const event = reflector.get(MetadataKey.EVENT_CONFIG, handler); + const event = reflector.get(MetadataKey.EventConfig, handler); if (!event) { continue; } diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 27c623cc89..3550529bfc 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -41,7 +41,7 @@ export class JobRepository { const instance = this.moduleRef.get(Service); for (const methodName of getMethodNames(instance)) { const handler = instance[methodName]; - const config = reflector.get(MetadataKey.JOB_CONFIG, handler); + const config = reflector.get(MetadataKey.JobConfig, handler); if (!config) { continue; } @@ -99,7 +99,7 @@ export class JobRepository { const item = this.handlers[name as JobName]; if (!item) { this.logger.warn(`Skipping unknown job: "${name}"`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } return item.handler(data); @@ -205,20 +205,20 @@ export class JobRepository { private getJobOptions(item: JobItem): JobsOptions | null { switch (item.name) { - case JobName.NOTIFY_ALBUM_UPDATE: { + case JobName.NotifyAlbumUpdate: { return { jobId: `${item.data.id}/${item.data.recipientId}`, delay: item.data?.delay, }; } - case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { + case JobName.StorageTemplateMigrationSingle: { return { jobId: item.data.id }; } - case JobName.GENERATE_PERSON_THUMBNAIL: { + case JobName.GeneratePersonThumbnail: { return { priority: 1 }; } - case JobName.QUEUE_FACIAL_RECOGNITION: { - return { jobId: JobName.QUEUE_FACIAL_RECOGNITION }; + case JobName.QueueFacialRecognition: { + return { jobId: JobName.QueueFacialRecognition }; } default: { return null; diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 9f26191cfb..68102ab765 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -79,7 +79,7 @@ export class LibraryRepository { eb.fn .countAll() .filterWhere((eb) => - eb.and([eb('asset.type', '=', AssetType.IMAGE), eb('asset.visibility', '!=', AssetVisibility.HIDDEN)]), + eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]), ) .as('photos'), ) @@ -87,7 +87,7 @@ export class LibraryRepository { eb.fn .countAll() .filterWhere((eb) => - eb.and([eb('asset.type', '=', AssetType.VIDEO), eb('asset.visibility', '!=', AssetVisibility.HIDDEN)]), + eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]), ) .as('videos'), ) diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 393eeb9496..99bb1dbf18 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -22,7 +22,7 @@ describe(LoggingRepository.name, () => { describe('formatContext', () => { it('should use colors', () => { sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + sut.setAppName(ImmichWorker.Api); const logger = new MyConsoleLogger(clsMock, { color: true }); @@ -31,7 +31,7 @@ describe(LoggingRepository.name, () => { it('should not use colors when color is false', () => { sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + sut.setAppName(ImmichWorker.Api); const logger = new MyConsoleLogger(clsMock, { color: false }); diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 2ac3715a50..1833168f3e 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -8,7 +8,7 @@ import { ConfigRepository } from 'src/repositories/config.repository'; type LogDetails = any; type LogFunction = () => string; -const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; +const LOG_LEVELS = [LogLevel.Verbose, LogLevel.Debug, LogLevel.Log, LogLevel.Warn, LogLevel.Error, LogLevel.Fatal]; enum LogColor { RED = 31, @@ -20,7 +20,7 @@ enum LogColor { } let appName: string | undefined; -let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; +let logLevels: LogLevel[] = [LogLevel.Log, LogLevel.Warn, LogLevel.Error, LogLevel.Fatal]; export class MyConsoleLogger extends ConsoleLogger { private isColorEnabled: boolean; @@ -106,35 +106,35 @@ export class LoggingRepository { } verbose(message: string, ...details: LogDetails) { - this.handleMessage(LogLevel.VERBOSE, message, details); + this.handleMessage(LogLevel.Verbose, message, details); } verboseFn(message: LogFunction, ...details: LogDetails) { - this.handleFunction(LogLevel.VERBOSE, message, details); + this.handleFunction(LogLevel.Verbose, message, details); } debug(message: string, ...details: LogDetails) { - this.handleMessage(LogLevel.DEBUG, message, details); + this.handleMessage(LogLevel.Debug, message, details); } debugFn(message: LogFunction, ...details: LogDetails) { - this.handleFunction(LogLevel.DEBUG, message, details); + this.handleFunction(LogLevel.Debug, message, details); } log(message: string, ...details: LogDetails) { - this.handleMessage(LogLevel.LOG, message, details); + this.handleMessage(LogLevel.Log, message, details); } warn(message: string, ...details: LogDetails) { - this.handleMessage(LogLevel.WARN, message, details); + this.handleMessage(LogLevel.Warn, message, details); } error(message: string | Error, ...details: LogDetails) { - this.handleMessage(LogLevel.ERROR, message, details); + this.handleMessage(LogLevel.Error, message, details); } fatal(message: string, ...details: LogDetails) { - this.handleMessage(LogLevel.FATAL, message, details); + this.handleMessage(LogLevel.Fatal, message, details); } private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { @@ -145,32 +145,32 @@ export class LoggingRepository { private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) { switch (level) { - case LogLevel.VERBOSE: { + case LogLevel.Verbose: { this.logger.verbose(message, ...details); break; } - case LogLevel.DEBUG: { + case LogLevel.Debug: { this.logger.debug(message, ...details); break; } - case LogLevel.LOG: { + case LogLevel.Log: { this.logger.log(message, ...details); break; } - case LogLevel.WARN: { + case LogLevel.Warn: { this.logger.warn(message, ...details); break; } - case LogLevel.ERROR: { + case LogLevel.Error: { this.logger.error(message, ...details); break; } - case LogLevel.FATAL: { + case LogLevel.Fatal: { this.logger.fatal(message, ...details); break; } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 64c8a6229d..d1f60791c3 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -61,14 +61,14 @@ export class MapRepository { const geodataDate = await readFile(resourcePaths.geodata.dateFile, 'utf8'); // TODO move to service init - const geocodingMetadata = await this.metadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + const geocodingMetadata = await this.metadataRepository.get(SystemMetadataKey.ReverseGeocodingState); if (geocodingMetadata?.lastUpdate === geodataDate) { return; } await Promise.all([this.importGeodata(), this.importNaturalEarthCountries()]); - await this.metadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { + await this.metadataRepository.set(SystemMetadataKey.ReverseGeocodingState, { lastUpdate: geodataDate, lastImportFileName: citiesFile, }); @@ -102,13 +102,13 @@ export class MapRepository { .$if(isArchived === true, (qb) => qb.where((eb) => eb.or([ - eb('asset.visibility', '=', AssetVisibility.TIMELINE), - eb('asset.visibility', '=', AssetVisibility.ARCHIVE), + eb('asset.visibility', '=', AssetVisibility.Timeline), + eb('asset.visibility', '=', AssetVisibility.Archive), ]), ), ) .$if(isArchived === false || isArchived === undefined, (qb) => - qb.where('asset.visibility', '=', AssetVisibility.TIMELINE), + qb.where('asset.visibility', '=', AssetVisibility.Timeline), ) .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 33cf4e3e03..6266acf0ed 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -55,28 +55,28 @@ export class MediaRepository { async extract(input: string): Promise { try { const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input); - return { buffer, format: RawExtractedFormat.JPEG }; + return { buffer, format: RawExtractedFormat.Jpeg }; } catch (error: any) { this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message); } try { const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input); - return { buffer, format: RawExtractedFormat.JPEG }; + return { buffer, format: RawExtractedFormat.Jpeg }; } catch (error: any) { this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message); } try { const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input); - return { buffer, format: RawExtractedFormat.JXL }; + return { buffer, format: RawExtractedFormat.Jxl }; } catch (error: any) { this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message); } try { const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input); - return { buffer, format: RawExtractedFormat.JPEG }; + return { buffer, format: RawExtractedFormat.Jpeg }; } catch (error: any) { this.logger.debug('Could not extract preview buffer from image', error.message); return null; @@ -142,7 +142,7 @@ export class MediaRepository { limitInputPixels: false, raw: options.raw, }) - .pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16') + .pipelineColorspace(options.colorspace === Colorspace.Srgb ? 'srgb' : 'rgb16') .withIccProfile(options.colorspace); if (!options.raw) { @@ -267,7 +267,7 @@ export class MediaRepository { const { frameCount, percentInterval } = options.progress; const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); - if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + if (this.logger.isLevelEnabled(LogLevel.Debug) && frameCount && frameInterval) { let lastProgressFrame: number = 0; ffmpegCall.on('progress', (progress: ProgressEvent) => { if (progress.frames - lastProgressFrame < frameInterval) { diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 7cf03508be..65b4cb3df7 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -19,7 +19,7 @@ export class MemoryRepository implements IBulkAsset { .deleteFrom('memory_asset') .using('asset') .whereRef('memory_asset.assetsId', '=', 'asset.id') - .where('asset.visibility', '!=', AssetVisibility.TIMELINE) + .where('asset.visibility', '!=', AssetVisibility.Timeline) .execute(); return this.db @@ -67,7 +67,7 @@ export class MemoryRepository implements IBulkAsset { .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetsId') .whereRef('memory_asset.memoriesId', '=', 'memory.id') .orderBy('asset.fileCreatedAt', 'asc') - .where('asset.visibility', '=', sql.lit(AssetVisibility.TIMELINE)) + .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .where('asset.deletedAt', 'is', null), ).as('assets'), ) @@ -158,7 +158,7 @@ export class MemoryRepository implements IBulkAsset { .innerJoin('memory_asset', 'asset.id', 'memory_asset.assetsId') .whereRef('memory_asset.memoriesId', '=', 'memory.id') .orderBy('asset.fileCreatedAt', 'asc') - .where('asset.visibility', '=', sql.lit(AssetVisibility.TIMELINE)) + .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .where('asset.deletedAt', 'is', null), ).as('assets'), ) diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index e416a65249..2ea69eba27 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -48,7 +48,7 @@ export class MoveRepository { eb.selectFrom('asset').select('id').whereRef('asset.id', '=', 'move_history.entityId'), ), ) - .where('move_history.pathType', '=', sql.lit(AssetPathType.ORIGINAL)) + .where('move_history.pathType', '=', sql.lit(AssetPathType.Original)) .execute(); } @@ -56,7 +56,7 @@ export class MoveRepository { async cleanMoveHistorySingle(assetId: string): Promise { await this.db .deleteFrom('move_history') - .where('move_history.pathType', '=', sql.lit(AssetPathType.ORIGINAL)) + .where('move_history.pathType', '=', sql.lit(AssetPathType.Original)) .where('entityId', '=', assetId) .execute(); } diff --git a/server/src/repositories/oauth.repository.ts b/server/src/repositories/oauth.repository.ts index 357b52a77a..9a436e4b9a 100644 --- a/server/src/repositories/oauth.repository.ts +++ b/server/src/repositories/oauth.repository.ts @@ -138,11 +138,11 @@ export class OAuthRepository { } switch (tokenEndpointAuthMethod) { - case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST: { + case OAuthTokenEndpointAuthMethod.ClientSecretPost: { return ClientSecretPost(clientSecret); } - case OAuthTokenEndpointAuthMethod.CLIENT_SECRET_BASIC: { + case OAuthTokenEndpointAuthMethod.ClientSecretBasic: { return ClientSecretBasic(clientSecret); } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 1885a196ff..5b7d1d3700 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -151,7 +151,7 @@ export class PersonRepository { .innerJoin('asset', (join) => join .onRef('asset_face.assetId', '=', 'asset.id') - .on('asset.visibility', '=', sql.lit(AssetVisibility.TIMELINE)) + .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null), ) .where('person.ownerId', '=', userId) @@ -276,7 +276,7 @@ export class PersonRepository { .selectFrom('asset_file') .select('asset_file.path') .whereRef('asset_file.assetId', '=', 'asset.id') - .where('asset_file.type', '=', sql.lit(AssetFileType.PREVIEW)) + .where('asset_file.type', '=', sql.lit(AssetFileType.Preview)) .as('previewPath'), ) .where('person.id', '=', id) @@ -341,7 +341,7 @@ export class PersonRepository { join .onRef('asset.id', '=', 'asset_face.assetId') .on('asset_face.personId', '=', personId) - .on('asset.visibility', '=', sql.lit(AssetVisibility.TIMELINE)) + .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null), ) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) @@ -369,7 +369,7 @@ export class PersonRepository { eb .selectFrom('asset') .whereRef('asset.id', '=', 'asset_face.assetId') - .where('asset.visibility', '=', sql.lit(AssetVisibility.TIMELINE)) + .where('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .where('asset.deletedAt', 'is', null), ), ), diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index fe8ad563bb..61e0cc1e29 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -256,7 +256,7 @@ export class SearchRepository { } return this.db.transaction().execute(async (trx) => { - await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.CLIP])}`.execute(trx); + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx); const items = await searchAssetBuilder(trx, options) .selectAll('asset') .innerJoin('smart_search', 'asset.id', 'smart_search.assetId') @@ -284,7 +284,7 @@ export class SearchRepository { } return this.db.transaction().execute(async (trx) => { - await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.FACE])}`.execute(trx); + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Face])}`.execute(trx); return await trx .with('cte', (qb) => qb @@ -351,8 +351,8 @@ export class SearchRepository { .select(['city', 'assetId']) .innerJoin('asset', 'asset.id', 'asset_exif.assetId') .where('asset.ownerId', '=', anyUuid(userIds)) - .where('asset.visibility', '=', AssetVisibility.TIMELINE) - .where('asset.type', '=', AssetType.IMAGE) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.type', '=', AssetType.Image) .where('asset.deletedAt', 'is', null) .orderBy('city') .limit(1); @@ -367,8 +367,8 @@ export class SearchRepository { .select(['city', 'assetId']) .innerJoin('asset', 'asset.id', 'asset_exif.assetId') .where('asset.ownerId', '=', anyUuid(userIds)) - .where('asset.visibility', '=', AssetVisibility.TIMELINE) - .where('asset.type', '=', AssetType.IMAGE) + .where('asset.visibility', '=', AssetVisibility.Timeline) + .where('asset.type', '=', AssetType.Image) .where('asset.deletedAt', 'is', null) .whereRef('asset_exif.city', '>', 'cte.city') .orderBy('city') @@ -450,7 +450,7 @@ export class SearchRepository { .distinctOn(field) .innerJoin('asset', 'asset.id', 'asset_exif.assetId') .where('ownerId', '=', anyUuid(userIds)) - .where('visibility', '=', AssetVisibility.TIMELINE) + .where('visibility', '=', AssetVisibility.Timeline) .where('deletedAt', 'is', null) .where(field, 'is not', null); } diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index d61333fcd6..d5fb3be47d 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -103,7 +103,7 @@ export class SharedLinkRepository { .select((eb) => eb.fn.toJson('album').$castTo().as('album')) .where('shared_link.id', '=', id) .where('shared_link.userId', '=', userId) - .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) + .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) .orderBy('shared_link.createdAt', 'desc') .executeTakeFirst(); } @@ -165,7 +165,7 @@ export class SharedLinkRepository { (join) => join.onTrue(), ) .select((eb) => eb.fn.toJson('album').$castTo().as('album')) - .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) + .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) .$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!)) .orderBy('shared_link.createdAt', 'desc') .distinctOn(['shared_link.createdAt']) @@ -185,7 +185,7 @@ export class SharedLinkRepository { eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'), ).as('user'), ]) - .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.INDIVIDUAL), eb('album.id', 'is not', null)])) + .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) .executeTakeFirst(); } diff --git a/server/src/repositories/telemetry.repository.ts b/server/src/repositories/telemetry.repository.ts index fc680ddcc5..5fbbb76cf7 100644 --- a/server/src/repositories/telemetry.repository.ts +++ b/server/src/repositories/telemetry.repository.ts @@ -112,21 +112,21 @@ export class TelemetryRepository { const { telemetry } = this.configRepository.getEnv(); const { metrics } = telemetry; - this.api = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.API) }); - this.host = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.HOST) }); - this.jobs = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.JOB) }); - this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.REPO) }); + this.api = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Api) }); + this.host = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Host) }); + this.jobs = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Job) }); + this.repo = new MetricGroupRepository(metricService).configure({ enabled: metrics.has(ImmichTelemetry.Repo) }); } setup({ repositories }: { repositories: ClassConstructor[] }) { const { telemetry } = this.configRepository.getEnv(); const { metrics } = telemetry; - if (!metrics.has(ImmichTelemetry.REPO)) { + if (!metrics.has(ImmichTelemetry.Repo)) { return; } for (const Repository of repositories) { - const isEnabled = this.reflect.get(MetadataKey.TELEMETRY_ENABLED, Repository) ?? true; + const isEnabled = this.reflect.get(MetadataKey.TelemetryEnabled, Repository) ?? true; if (!isEnabled) { this.logger.debug(`Telemetry disabled for ${Repository.name}`); continue; diff --git a/server/src/repositories/trash.repository.ts b/server/src/repositories/trash.repository.ts index ee6fe3ace1..f6f13188d4 100644 --- a/server/src/repositories/trash.repository.ts +++ b/server/src/repositories/trash.repository.ts @@ -8,7 +8,7 @@ export class TrashRepository { constructor(@InjectKysely() private db: Kysely) {} getDeletedIds(): AsyncIterableIterator<{ id: string }> { - return this.db.selectFrom('asset').select(['id']).where('status', '=', AssetStatus.DELETED).stream(); + return this.db.selectFrom('asset').select(['id']).where('status', '=', AssetStatus.Deleted).stream(); } @GenerateSql({ params: [DummyValue.UUID] }) @@ -16,8 +16,8 @@ export class TrashRepository { const { numUpdatedRows } = await this.db .updateTable('asset') .where('ownerId', '=', userId) - .where('status', '=', AssetStatus.TRASHED) - .set({ status: AssetStatus.ACTIVE, deletedAt: null }) + .where('status', '=', AssetStatus.Trashed) + .set({ status: AssetStatus.Active, deletedAt: null }) .executeTakeFirst(); return Number(numUpdatedRows); @@ -28,8 +28,8 @@ export class TrashRepository { const { numUpdatedRows } = await this.db .updateTable('asset') .where('ownerId', '=', userId) - .where('status', '=', AssetStatus.TRASHED) - .set({ status: AssetStatus.DELETED }) + .where('status', '=', AssetStatus.Trashed) + .set({ status: AssetStatus.Deleted }) .executeTakeFirst(); return Number(numUpdatedRows); @@ -43,9 +43,9 @@ export class TrashRepository { const { numUpdatedRows } = await this.db .updateTable('asset') - .where('status', '=', AssetStatus.TRASHED) + .where('status', '=', AssetStatus.Trashed) .where('id', 'in', ids) - .set({ status: AssetStatus.ACTIVE, deletedAt: null }) + .set({ status: AssetStatus.Active, deletedAt: null }) .executeTakeFirst(); return Number(numUpdatedRows); diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index f809280d86..715aa2dc32 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -187,7 +187,7 @@ export class UserRepository { restore(id: string) { return this.db .updateTable('user') - .set({ status: UserStatus.ACTIVE, deletedAt: null }) + .set({ status: UserStatus.Active, deletedAt: null }) .where('user.id', '=', asUuid(id)) .returning(columns.userAdmin) .returning(withMetadata) @@ -229,8 +229,8 @@ export class UserRepository { .countAll() .filterWhere((eb) => eb.and([ - eb('asset.type', '=', sql.lit(AssetType.IMAGE)), - eb('asset.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)), + eb('asset.type', '=', sql.lit(AssetType.Image)), + eb('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ]), ) .as('photos'), @@ -238,8 +238,8 @@ export class UserRepository { .countAll() .filterWhere((eb) => eb.and([ - eb('asset.type', '=', sql.lit(AssetType.VIDEO)), - eb('asset.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)), + eb('asset.type', '=', sql.lit(AssetType.Video)), + eb('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)), ]), ) .as('videos'), @@ -254,7 +254,7 @@ export class UserRepository { eb.fn .sum('asset_exif.fileSizeInByte') .filterWhere((eb) => - eb.and([eb('asset.libraryId', 'is', null), eb('asset.type', '=', sql.lit(AssetType.IMAGE))]), + eb.and([eb('asset.libraryId', 'is', null), eb('asset.type', '=', sql.lit(AssetType.Image))]), ), eb.lit(0), ) @@ -264,7 +264,7 @@ export class UserRepository { eb.fn .sum('asset_exif.fileSizeInByte') .filterWhere((eb) => - eb.and([eb('asset.libraryId', 'is', null), eb('asset.type', '=', sql.lit(AssetType.VIDEO))]), + eb.and([eb('asset.libraryId', 'is', null), eb('asset.type', '=', sql.lit(AssetType.Video))]), ), eb.lit(0), ) diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index 0fd74d299f..93c1280191 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -15,7 +15,7 @@ export class ViewRepository { .select((eb) => eb.fn('substring', ['asset.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath')) .distinct() .where('ownerId', '=', asUuid(userId)) - .where('visibility', '=', AssetVisibility.TIMELINE) + .where('visibility', '=', AssetVisibility.Timeline) .where('deletedAt', 'is', null) .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) @@ -34,7 +34,7 @@ export class ViewRepository { .selectAll('asset') .$call(withExif) .where('ownerId', '=', asUuid(userId)) - .where('visibility', '=', AssetVisibility.TIMELINE) + .where('visibility', '=', AssetVisibility.Timeline) .where('deletedAt', 'is', null) .where('fileCreatedAt', 'is not', null) .where('fileModifiedAt', 'is not', null) diff --git a/server/src/schema/migrations/1744910873969-InitialMigration.ts b/server/src/schema/migrations/1744910873969-InitialMigration.ts index 63625a69ad..53a55d860e 100644 --- a/server/src/schema/migrations/1744910873969-InitialMigration.ts +++ b/server/src/schema/migrations/1744910873969-InitialMigration.ts @@ -16,9 +16,7 @@ export async function up(db: Kysely): Promise { rows: [lastMigration], } = await lastMigrationSql.execute(db); if (lastMigration?.name !== 'AddMissingIndex1744910873956') { - throw new Error( - 'Invalid upgrade path. For more information, see https://immich.app/errors#typeorm-upgrade', - ); + throw new Error('Invalid upgrade path. For more information, see https://immich.app/errors#typeorm-upgrade'); } logger.log('Database has up to date TypeORM migrations, skipping initial Kysely migration'); return; @@ -108,152 +106,344 @@ export async function up(db: Kysely): Promise { RETURN NULL; END; $$;`.execute(db); - if (vectorExtension === DatabaseExtension.VECTORS) { + if (vectorExtension === DatabaseExtension.Vectors) { await sql`SET search_path TO "$user", public, vectors`.execute(db); } await sql`CREATE TYPE "assets_status_enum" AS ENUM ('active','trashed','deleted');`.execute(db); await sql`CREATE TYPE "sourcetype" AS ENUM ('machine-learning','exif','manual');`.execute(db); - await sql`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password" character varying NOT NULL DEFAULT '', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "profileImagePath" character varying NOT NULL DEFAULT '', "isAdmin" boolean NOT NULL DEFAULT false, "shouldChangePassword" boolean NOT NULL DEFAULT true, "deletedAt" timestamp with time zone, "oauthId" character varying NOT NULL DEFAULT '', "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "storageLabel" character varying, "name" character varying NOT NULL DEFAULT '', "quotaSizeInBytes" bigint, "quotaUsageInBytes" bigint NOT NULL DEFAULT 0, "status" character varying NOT NULL DEFAULT 'active', "profileChangedAt" timestamp with time zone NOT NULL DEFAULT now(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "libraries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "ownerId" uuid NOT NULL, "importPaths" text[] NOT NULL, "exclusionPatterns" text[] NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "refreshedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "asset_stack" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "primaryAssetId" uuid NOT NULL, "ownerId" uuid NOT NULL);`.execute(db); - await sql`CREATE TABLE "assets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "deviceAssetId" character varying NOT NULL, "ownerId" uuid NOT NULL, "deviceId" character varying NOT NULL, "type" character varying NOT NULL, "originalPath" character varying NOT NULL, "fileCreatedAt" timestamp with time zone NOT NULL, "fileModifiedAt" timestamp with time zone NOT NULL, "isFavorite" boolean NOT NULL DEFAULT false, "duration" character varying, "encodedVideoPath" character varying DEFAULT '', "checksum" bytea NOT NULL, "isVisible" boolean NOT NULL DEFAULT true, "livePhotoVideoId" uuid, "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, "originalFileName" character varying NOT NULL, "sidecarPath" character varying, "thumbhash" bytea, "isOffline" boolean NOT NULL DEFAULT false, "libraryId" uuid, "isExternal" boolean NOT NULL DEFAULT false, "deletedAt" timestamp with time zone, "localDateTime" timestamp with time zone NOT NULL, "stackId" uuid, "duplicateId" uuid, "status" assets_status_enum NOT NULL DEFAULT 'active', "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "albums" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ownerId" uuid NOT NULL, "albumName" character varying NOT NULL DEFAULT 'Untitled Album', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "albumThumbnailAssetId" uuid, "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "description" text NOT NULL DEFAULT '', "deletedAt" timestamp with time zone, "isActivityEnabled" boolean NOT NULL DEFAULT true, "order" character varying NOT NULL DEFAULT 'desc', "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); + await sql`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password" character varying NOT NULL DEFAULT '', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "profileImagePath" character varying NOT NULL DEFAULT '', "isAdmin" boolean NOT NULL DEFAULT false, "shouldChangePassword" boolean NOT NULL DEFAULT true, "deletedAt" timestamp with time zone, "oauthId" character varying NOT NULL DEFAULT '', "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "storageLabel" character varying, "name" character varying NOT NULL DEFAULT '', "quotaSizeInBytes" bigint, "quotaUsageInBytes" bigint NOT NULL DEFAULT 0, "status" character varying NOT NULL DEFAULT 'active', "profileChangedAt" timestamp with time zone NOT NULL DEFAULT now(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "libraries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "ownerId" uuid NOT NULL, "importPaths" text[] NOT NULL, "exclusionPatterns" text[] NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "refreshedAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "asset_stack" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "primaryAssetId" uuid NOT NULL, "ownerId" uuid NOT NULL);`.execute( + db, + ); + await sql`CREATE TABLE "assets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "deviceAssetId" character varying NOT NULL, "ownerId" uuid NOT NULL, "deviceId" character varying NOT NULL, "type" character varying NOT NULL, "originalPath" character varying NOT NULL, "fileCreatedAt" timestamp with time zone NOT NULL, "fileModifiedAt" timestamp with time zone NOT NULL, "isFavorite" boolean NOT NULL DEFAULT false, "duration" character varying, "encodedVideoPath" character varying DEFAULT '', "checksum" bytea NOT NULL, "isVisible" boolean NOT NULL DEFAULT true, "livePhotoVideoId" uuid, "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, "originalFileName" character varying NOT NULL, "sidecarPath" character varying, "thumbhash" bytea, "isOffline" boolean NOT NULL DEFAULT false, "libraryId" uuid, "isExternal" boolean NOT NULL DEFAULT false, "deletedAt" timestamp with time zone, "localDateTime" timestamp with time zone NOT NULL, "stackId" uuid, "duplicateId" uuid, "status" assets_status_enum NOT NULL DEFAULT 'active', "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "albums" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ownerId" uuid NOT NULL, "albumName" character varying NOT NULL DEFAULT 'Untitled Album', "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "albumThumbnailAssetId" uuid, "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "description" text NOT NULL DEFAULT '', "deletedAt" timestamp with time zone, "isActivityEnabled" boolean NOT NULL DEFAULT true, "order" character varying NOT NULL DEFAULT 'desc', "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); await sql`COMMENT ON COLUMN "albums"."albumThumbnailAssetId" IS 'Asset ID to be used as thumbnail';`.execute(db); - await sql`CREATE TABLE "activity" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "assetId" uuid, "comment" text, "isLiked" boolean NOT NULL DEFAULT false, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "albums_assets_assets" ("albumsId" uuid NOT NULL, "assetsId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now());`.execute(db); - await sql`CREATE TABLE "albums_shared_users_users" ("albumsId" uuid NOT NULL, "usersId" uuid NOT NULL, "role" character varying NOT NULL DEFAULT 'editor');`.execute(db); - await sql`CREATE TABLE "api_keys" ("name" character varying NOT NULL, "key" character varying NOT NULL, "userId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permissions" character varying[] NOT NULL, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "assetId" uuid NOT NULL, "ownerId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); - await sql`CREATE TABLE "person" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "ownerId" uuid NOT NULL, "name" character varying NOT NULL DEFAULT '', "thumbnailPath" character varying NOT NULL DEFAULT '', "isHidden" boolean NOT NULL DEFAULT false, "birthDate" date, "faceAssetId" uuid, "isFavorite" boolean NOT NULL DEFAULT false, "color" character varying, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "asset_faces" ("assetId" uuid NOT NULL, "personId" uuid, "imageWidth" integer NOT NULL DEFAULT 0, "imageHeight" integer NOT NULL DEFAULT 0, "boundingBoxX1" integer NOT NULL DEFAULT 0, "boundingBoxY1" integer NOT NULL DEFAULT 0, "boundingBoxX2" integer NOT NULL DEFAULT 0, "boundingBoxY2" integer NOT NULL DEFAULT 0, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "sourceType" sourcetype NOT NULL DEFAULT 'machine-learning', "deletedAt" timestamp with time zone);`.execute(db); - await sql`CREATE TABLE "asset_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "assetId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "type" character varying NOT NULL, "path" character varying NOT NULL, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "asset_job_status" ("assetId" uuid NOT NULL, "facesRecognizedAt" timestamp with time zone, "metadataExtractedAt" timestamp with time zone, "duplicatesDetectedAt" timestamp with time zone, "previewAt" timestamp with time zone, "thumbnailAt" timestamp with time zone);`.execute(db); - await sql`CREATE TABLE "audit" ("id" serial NOT NULL, "entityType" character varying NOT NULL, "entityId" uuid NOT NULL, "action" character varying NOT NULL, "ownerId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now());`.execute(db); - await sql`CREATE TABLE "exif" ("assetId" uuid NOT NULL, "make" character varying, "model" character varying, "exifImageWidth" integer, "exifImageHeight" integer, "fileSizeInByte" bigint, "orientation" character varying, "dateTimeOriginal" timestamp with time zone, "modifyDate" timestamp with time zone, "lensModel" character varying, "fNumber" double precision, "focalLength" double precision, "iso" integer, "latitude" double precision, "longitude" double precision, "city" character varying, "state" character varying, "country" character varying, "description" text NOT NULL DEFAULT '', "fps" double precision, "exposureTime" character varying, "livePhotoCID" character varying, "timeZone" character varying, "projectionType" character varying, "profileDescription" character varying, "colorspace" character varying, "bitsPerSample" integer, "autoStackId" character varying, "rating" integer, "updatedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); + await sql`CREATE TABLE "activity" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "assetId" uuid, "comment" text, "isLiked" boolean NOT NULL DEFAULT false, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "albums_assets_assets" ("albumsId" uuid NOT NULL, "assetsId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now());`.execute( + db, + ); + await sql`CREATE TABLE "albums_shared_users_users" ("albumsId" uuid NOT NULL, "usersId" uuid NOT NULL, "role" character varying NOT NULL DEFAULT 'editor');`.execute( + db, + ); + await sql`CREATE TABLE "api_keys" ("name" character varying NOT NULL, "key" character varying NOT NULL, "userId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permissions" character varying[] NOT NULL, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "assetId" uuid NOT NULL, "ownerId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute( + db, + ); + await sql`CREATE TABLE "person" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "ownerId" uuid NOT NULL, "name" character varying NOT NULL DEFAULT '', "thumbnailPath" character varying NOT NULL DEFAULT '', "isHidden" boolean NOT NULL DEFAULT false, "birthDate" date, "faceAssetId" uuid, "isFavorite" boolean NOT NULL DEFAULT false, "color" character varying, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "asset_faces" ("assetId" uuid NOT NULL, "personId" uuid, "imageWidth" integer NOT NULL DEFAULT 0, "imageHeight" integer NOT NULL DEFAULT 0, "boundingBoxX1" integer NOT NULL DEFAULT 0, "boundingBoxY1" integer NOT NULL DEFAULT 0, "boundingBoxX2" integer NOT NULL DEFAULT 0, "boundingBoxY2" integer NOT NULL DEFAULT 0, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "sourceType" sourcetype NOT NULL DEFAULT 'machine-learning', "deletedAt" timestamp with time zone);`.execute( + db, + ); + await sql`CREATE TABLE "asset_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "assetId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "type" character varying NOT NULL, "path" character varying NOT NULL, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "asset_job_status" ("assetId" uuid NOT NULL, "facesRecognizedAt" timestamp with time zone, "metadataExtractedAt" timestamp with time zone, "duplicatesDetectedAt" timestamp with time zone, "previewAt" timestamp with time zone, "thumbnailAt" timestamp with time zone);`.execute( + db, + ); + await sql`CREATE TABLE "audit" ("id" serial NOT NULL, "entityType" character varying NOT NULL, "entityId" uuid NOT NULL, "action" character varying NOT NULL, "ownerId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now());`.execute( + db, + ); + await sql`CREATE TABLE "exif" ("assetId" uuid NOT NULL, "make" character varying, "model" character varying, "exifImageWidth" integer, "exifImageHeight" integer, "fileSizeInByte" bigint, "orientation" character varying, "dateTimeOriginal" timestamp with time zone, "modifyDate" timestamp with time zone, "lensModel" character varying, "fNumber" double precision, "focalLength" double precision, "iso" integer, "latitude" double precision, "longitude" double precision, "city" character varying, "state" character varying, "country" character varying, "description" text NOT NULL DEFAULT '', "fps" double precision, "exposureTime" character varying, "livePhotoCID" character varying, "timeZone" character varying, "projectionType" character varying, "profileDescription" character varying, "colorspace" character varying, "bitsPerSample" integer, "autoStackId" character varying, "rating" integer, "updatedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); await sql`CREATE TABLE "face_search" ("faceId" uuid NOT NULL, "embedding" vector(512) NOT NULL);`.execute(db); - await sql`CREATE TABLE "geodata_places" ("id" integer NOT NULL, "name" character varying(200) NOT NULL, "longitude" double precision NOT NULL, "latitude" double precision NOT NULL, "countryCode" character(2) NOT NULL, "admin1Code" character varying(20), "admin2Code" character varying(80), "modificationDate" date NOT NULL, "admin1Name" character varying, "admin2Name" character varying, "alternateNames" character varying);`.execute(db); - await sql`CREATE TABLE "memories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "ownerId" uuid NOT NULL, "type" character varying NOT NULL, "data" jsonb NOT NULL, "isSaved" boolean NOT NULL DEFAULT false, "memoryAt" timestamp with time zone NOT NULL, "seenAt" timestamp with time zone, "showAt" timestamp with time zone, "hideAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); + await sql`CREATE TABLE "geodata_places" ("id" integer NOT NULL, "name" character varying(200) NOT NULL, "longitude" double precision NOT NULL, "latitude" double precision NOT NULL, "countryCode" character(2) NOT NULL, "admin1Code" character varying(20), "admin2Code" character varying(80), "modificationDate" date NOT NULL, "admin1Name" character varying, "admin2Name" character varying, "alternateNames" character varying);`.execute( + db, + ); + await sql`CREATE TABLE "memories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "deletedAt" timestamp with time zone, "ownerId" uuid NOT NULL, "type" character varying NOT NULL, "data" jsonb NOT NULL, "isSaved" boolean NOT NULL DEFAULT false, "memoryAt" timestamp with time zone NOT NULL, "seenAt" timestamp with time zone, "showAt" timestamp with time zone, "hideAt" timestamp with time zone, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); await sql`CREATE TABLE "memories_assets_assets" ("memoriesId" uuid NOT NULL, "assetsId" uuid NOT NULL);`.execute(db); - await sql`CREATE TABLE "move_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "entityId" uuid NOT NULL, "pathType" character varying NOT NULL, "oldPath" character varying NOT NULL, "newPath" character varying NOT NULL);`.execute(db); - await sql`CREATE TABLE "naturalearth_countries" ("id" integer NOT NULL GENERATED ALWAYS AS IDENTITY, "admin" character varying(50) NOT NULL, "admin_a3" character varying(3) NOT NULL, "type" character varying(50) NOT NULL, "coordinates" polygon NOT NULL);`.execute(db); - await sql`CREATE TABLE "partners_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db); - await sql`CREATE TABLE "partners" ("sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "inTimeline" boolean NOT NULL DEFAULT false, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "userId" uuid NOT NULL, "deviceType" character varying NOT NULL DEFAULT '', "deviceOS" character varying NOT NULL DEFAULT '', "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" uuid NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "expiresAt" timestamp with time zone, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, "allowDownload" boolean NOT NULL DEFAULT true, "showExif" boolean NOT NULL DEFAULT true, "password" character varying);`.execute(db); + await sql`CREATE TABLE "move_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "entityId" uuid NOT NULL, "pathType" character varying NOT NULL, "oldPath" character varying NOT NULL, "newPath" character varying NOT NULL);`.execute( + db, + ); + await sql`CREATE TABLE "naturalearth_countries" ("id" integer NOT NULL GENERATED ALWAYS AS IDENTITY, "admin" character varying(50) NOT NULL, "admin_a3" character varying(3) NOT NULL, "type" character varying(50) NOT NULL, "coordinates" polygon NOT NULL);`.execute( + db, + ); + await sql`CREATE TABLE "partners_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute( + db, + ); + await sql`CREATE TABLE "partners" ("sharedById" uuid NOT NULL, "sharedWithId" uuid NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "inTimeline" boolean NOT NULL DEFAULT false, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "userId" uuid NOT NULL, "deviceType" character varying NOT NULL DEFAULT '', "deviceOS" character varying NOT NULL DEFAULT '', "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" uuid NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "expiresAt" timestamp with time zone, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, "allowDownload" boolean NOT NULL DEFAULT true, "showExif" boolean NOT NULL DEFAULT true, "password" character varying);`.execute( + db, + ); await sql`CREATE TABLE "shared_link__asset" ("assetsId" uuid NOT NULL, "sharedLinksId" uuid NOT NULL);`.execute(db); await sql`CREATE TABLE "smart_search" ("assetId" uuid NOT NULL, "embedding" vector(512) NOT NULL);`.execute(db); await sql`ALTER TABLE "smart_search" ALTER COLUMN "embedding" SET STORAGE EXTERNAL;`.execute(db); - await sql`CREATE TABLE "session_sync_checkpoints" ("sessionId" uuid NOT NULL, "type" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "ack" character varying NOT NULL, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); + await sql`CREATE TABLE "session_sync_checkpoints" ("sessionId" uuid NOT NULL, "type" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "ack" character varying NOT NULL, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); await sql`CREATE TABLE "system_metadata" ("key" character varying NOT NULL, "value" jsonb NOT NULL);`.execute(db); - await sql`CREATE TABLE "tags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "value" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "color" character varying, "parentId" uuid, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); + await sql`CREATE TABLE "tags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "value" character varying NOT NULL, "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), "color" character varying, "parentId" uuid, "updateId" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); await sql`CREATE TABLE "tag_asset" ("assetsId" uuid NOT NULL, "tagsId" uuid NOT NULL);`.execute(db); await sql`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL);`.execute(db); - await sql`CREATE TABLE "users_audit" ("userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), "id" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute(db); - await sql`CREATE TABLE "user_metadata" ("userId" uuid NOT NULL, "key" character varying NOT NULL, "value" jsonb NOT NULL);`.execute(db); - await sql`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "version" character varying NOT NULL);`.execute(db); + await sql`CREATE TABLE "users_audit" ("userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), "id" uuid NOT NULL DEFAULT immich_uuid_v7());`.execute( + db, + ); + await sql`CREATE TABLE "user_metadata" ("userId" uuid NOT NULL, "key" character varying NOT NULL, "value" jsonb NOT NULL);`.execute( + db, + ); + await sql`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" timestamp with time zone NOT NULL DEFAULT now(), "version" character varying NOT NULL);`.execute( + db, + ); await sql`ALTER TABLE "users" ADD CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "libraries" ADD CONSTRAINT "PK_505fedfcad00a09b3734b4223de" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "PK_74a27e7fcbd5852463d0af3034b" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "assets" ADD CONSTRAINT "PK_da96729a8b113377cfb6a62439c" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "albums" ADD CONSTRAINT "PK_7f71c7b5bc7c87b8f94c9a93a00" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "activity" ADD CONSTRAINT "PK_24625a1d6b1b089c8ae206fe467" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "PK_c67bc36fa845fb7b18e0e398180" PRIMARY KEY ("albumsId", "assetsId");`.execute(db); - await sql`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "PK_7df55657e0b2e8b626330a0ebc8" PRIMARY KEY ("albumsId", "usersId");`.execute(db); + await sql`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "PK_c67bc36fa845fb7b18e0e398180" PRIMARY KEY ("albumsId", "assetsId");`.execute( + db, + ); + await sql`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "PK_7df55657e0b2e8b626330a0ebc8" PRIMARY KEY ("albumsId", "usersId");`.execute( + db, + ); await sql`ALTER TABLE "api_keys" ADD CONSTRAINT "PK_5c8a79801b44bd27b79228e1dad" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "assets_audit" ADD CONSTRAINT "PK_99bd5c015f81a641927a32b4212" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "person" ADD CONSTRAINT "PK_5fdaf670315c4b7e70cce85daa3" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "asset_faces" ADD CONSTRAINT "PK_6df76ab2eb6f5b57b7c2f1fc684" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "asset_files" ADD CONSTRAINT "PK_c41dc3e9ef5e1c57ca5a08a0004" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "asset_job_status" ADD CONSTRAINT "PK_420bec36fc02813bddf5c8b73d4" PRIMARY KEY ("assetId");`.execute(db); + await sql`ALTER TABLE "asset_job_status" ADD CONSTRAINT "PK_420bec36fc02813bddf5c8b73d4" PRIMARY KEY ("assetId");`.execute( + db, + ); await sql`ALTER TABLE "audit" ADD CONSTRAINT "PK_1d3d120ddaf7bc9b1ed68ed463a" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "exif" ADD CONSTRAINT "PK_c0117fdbc50b917ef9067740c44" PRIMARY KEY ("assetId");`.execute(db); await sql`ALTER TABLE "face_search" ADD CONSTRAINT "face_search_pkey" PRIMARY KEY ("faceId");`.execute(db); - await sql`ALTER TABLE "geodata_places" ADD CONSTRAINT "PK_c29918988912ef4036f3d7fbff4" PRIMARY KEY ("id");`.execute(db); + await sql`ALTER TABLE "geodata_places" ADD CONSTRAINT "PK_c29918988912ef4036f3d7fbff4" PRIMARY KEY ("id");`.execute( + db, + ); await sql`ALTER TABLE "memories" ADD CONSTRAINT "PK_aaa0692d9496fe827b0568612f8" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "PK_fcaf7112a013d1703c011c6793d" PRIMARY KEY ("memoriesId", "assetsId");`.execute(db); + await sql`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "PK_fcaf7112a013d1703c011c6793d" PRIMARY KEY ("memoriesId", "assetsId");`.execute( + db, + ); await sql`ALTER TABLE "move_history" ADD CONSTRAINT "PK_af608f132233acf123f2949678d" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "PK_21a6d86d1ab5d841648212e5353" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "partners_audit" ADD CONSTRAINT "PK_952b50217ff78198a7e380f0359" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "partners" ADD CONSTRAINT "PK_f1cc8f73d16b367f426261a8736" PRIMARY KEY ("sharedById", "sharedWithId");`.execute(db); + await sql`ALTER TABLE "naturalearth_countries" ADD CONSTRAINT "PK_21a6d86d1ab5d841648212e5353" PRIMARY KEY ("id");`.execute( + db, + ); + await sql`ALTER TABLE "partners_audit" ADD CONSTRAINT "PK_952b50217ff78198a7e380f0359" PRIMARY KEY ("id");`.execute( + db, + ); + await sql`ALTER TABLE "partners" ADD CONSTRAINT "PK_f1cc8f73d16b367f426261a8736" PRIMARY KEY ("sharedById", "sharedWithId");`.execute( + db, + ); await sql`ALTER TABLE "sessions" ADD CONSTRAINT "PK_48cb6b5c20faa63157b3c1baf7f" PRIMARY KEY ("id");`.execute(db); await sql`ALTER TABLE "shared_links" ADD CONSTRAINT "PK_642e2b0f619e4876e5f90a43465" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId");`.execute(db); + await sql`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId");`.execute( + db, + ); await sql`ALTER TABLE "smart_search" ADD CONSTRAINT "smart_search_pkey" PRIMARY KEY ("assetId");`.execute(db); - await sql`ALTER TABLE "session_sync_checkpoints" ADD CONSTRAINT "PK_b846ab547a702863ef7cd9412fb" PRIMARY KEY ("sessionId", "type");`.execute(db); - await sql`ALTER TABLE "system_metadata" ADD CONSTRAINT "PK_fa94f6857470fb5b81ec6084465" PRIMARY KEY ("key");`.execute(db); + await sql`ALTER TABLE "session_sync_checkpoints" ADD CONSTRAINT "PK_b846ab547a702863ef7cd9412fb" PRIMARY KEY ("sessionId", "type");`.execute( + db, + ); + await sql`ALTER TABLE "system_metadata" ADD CONSTRAINT "PK_fa94f6857470fb5b81ec6084465" PRIMARY KEY ("key");`.execute( + db, + ); await sql`ALTER TABLE "tags" ADD CONSTRAINT "PK_e7dc17249a1148a1970748eda99" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "tag_asset" ADD CONSTRAINT "PK_ef5346fe522b5fb3bc96454747e" PRIMARY KEY ("assetsId", "tagsId");`.execute(db); - await sql`ALTER TABLE "tags_closure" ADD CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant");`.execute(db); + await sql`ALTER TABLE "tag_asset" ADD CONSTRAINT "PK_ef5346fe522b5fb3bc96454747e" PRIMARY KEY ("assetsId", "tagsId");`.execute( + db, + ); + await sql`ALTER TABLE "tags_closure" ADD CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant");`.execute( + db, + ); await sql`ALTER TABLE "users_audit" ADD CONSTRAINT "PK_e9b2bdfd90e7eb5961091175180" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "user_metadata" ADD CONSTRAINT "PK_5931462150b3438cbc83277fe5a" PRIMARY KEY ("userId", "key");`.execute(db); - await sql`ALTER TABLE "version_history" ADD CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id");`.execute(db); - await sql`ALTER TABLE "libraries" ADD CONSTRAINT "FK_0f6fc2fb195f24d19b0fb0d57c1" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "FK_91704e101438fd0653f582426dc" FOREIGN KEY ("primaryAssetId") REFERENCES "assets" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;`.execute(db); - await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "FK_c05079e542fd74de3b5ecb5c1c8" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_2c5ac0d6fb58b238fd2068de67d" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_16294b83fa8c0149719a1f631ef" FOREIGN KEY ("livePhotoVideoId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db); - await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c" FOREIGN KEY ("libraryId") REFERENCES "libraries" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_f15d48fa3ea5e4bda05ca8ab207" FOREIGN KEY ("stackId") REFERENCES "asset_stack" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db); - await sql`ALTER TABLE "albums" ADD CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "albums" ADD CONSTRAINT "FK_05895aa505a670300d4816debce" FOREIGN KEY ("albumThumbnailAssetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db); - await sql`ALTER TABLE "activity" ADD CONSTRAINT "FK_1af8519996fbfb3684b58df280b" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "activity" ADD CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "activity" ADD CONSTRAINT "FK_8091ea76b12338cb4428d33d782" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "FK_e590fa396c6898fcd4a50e40927" FOREIGN KEY ("albumsId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "FK_4bd1303d199f4e72ccdf998c621" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "FK_427c350ad49bd3935a50baab737" FOREIGN KEY ("albumsId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "FK_f48513bf9bccefd6ff3ad30bd06" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "api_keys" ADD CONSTRAINT "FK_6c2e267ae764a9413b863a29342" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "person" ADD CONSTRAINT "FK_5527cc99f530a547093f9e577b6" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "asset_faces" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;`.execute(db); - await sql`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_02a43fd0b3c50fb6d7f0cb7282c" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_95ad7106dd7b484275443f580f9" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db); - await sql`ALTER TABLE "asset_files" ADD CONSTRAINT "FK_e3e103a5f1d8bc8402999286040" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "asset_job_status" ADD CONSTRAINT "FK_420bec36fc02813bddf5c8b73d4" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "exif" ADD CONSTRAINT "FK_c0117fdbc50b917ef9067740c44" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "face_search" ADD CONSTRAINT "face_search_faceId_fkey" FOREIGN KEY ("faceId") REFERENCES "asset_faces" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "memories" ADD CONSTRAINT "FK_575842846f0c28fa5da46c99b19" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e" FOREIGN KEY ("memoriesId") REFERENCES "memories" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "partners" ADD CONSTRAINT "FK_7e077a8b70b3530138610ff5e04" FOREIGN KEY ("sharedById") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "partners" ADD CONSTRAINT "FK_d7e875c6c60e661723dbf372fd3" FOREIGN KEY ("sharedWithId") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "smart_search" ADD CONSTRAINT "smart_search_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "session_sync_checkpoints" ADD CONSTRAINT "FK_d8ddd9d687816cc490432b3d4bc" FOREIGN KEY ("sessionId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); - await sql`ALTER TABLE "user_metadata" ADD CONSTRAINT "FK_6afb43681a21cf7815932bc38ac" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`ALTER TABLE "user_metadata" ADD CONSTRAINT "PK_5931462150b3438cbc83277fe5a" PRIMARY KEY ("userId", "key");`.execute( + db, + ); + await sql`ALTER TABLE "version_history" ADD CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id");`.execute( + db, + ); + await sql`ALTER TABLE "libraries" ADD CONSTRAINT "FK_0f6fc2fb195f24d19b0fb0d57c1" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "FK_91704e101438fd0653f582426dc" FOREIGN KEY ("primaryAssetId") REFERENCES "assets" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;`.execute( + db, + ); + await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "FK_c05079e542fd74de3b5ecb5c1c8" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_2c5ac0d6fb58b238fd2068de67d" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_16294b83fa8c0149719a1f631ef" FOREIGN KEY ("livePhotoVideoId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_9977c3c1de01c3d848039a6b90c" FOREIGN KEY ("libraryId") REFERENCES "libraries" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "assets" ADD CONSTRAINT "FK_f15d48fa3ea5e4bda05ca8ab207" FOREIGN KEY ("stackId") REFERENCES "asset_stack" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`ALTER TABLE "albums" ADD CONSTRAINT "FK_b22c53f35ef20c28c21637c85f4" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "albums" ADD CONSTRAINT "FK_05895aa505a670300d4816debce" FOREIGN KEY ("albumThumbnailAssetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "FK_1af8519996fbfb3684b58df280b" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "FK_3571467bcbe021f66e2bdce96ea" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "FK_8091ea76b12338cb4428d33d782" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "FK_e590fa396c6898fcd4a50e40927" FOREIGN KEY ("albumsId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "albums_assets_assets" ADD CONSTRAINT "FK_4bd1303d199f4e72ccdf998c621" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "FK_427c350ad49bd3935a50baab737" FOREIGN KEY ("albumsId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "albums_shared_users_users" ADD CONSTRAINT "FK_f48513bf9bccefd6ff3ad30bd06" FOREIGN KEY ("usersId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "api_keys" ADD CONSTRAINT "FK_6c2e267ae764a9413b863a29342" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "person" ADD CONSTRAINT "FK_5527cc99f530a547093f9e577b6" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "asset_faces" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;`.execute( + db, + ); + await sql`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_02a43fd0b3c50fb6d7f0cb7282c" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_95ad7106dd7b484275443f580f9" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`ALTER TABLE "asset_files" ADD CONSTRAINT "FK_e3e103a5f1d8bc8402999286040" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "asset_job_status" ADD CONSTRAINT "FK_420bec36fc02813bddf5c8b73d4" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "exif" ADD CONSTRAINT "FK_c0117fdbc50b917ef9067740c44" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "face_search" ADD CONSTRAINT "face_search_faceId_fkey" FOREIGN KEY ("faceId") REFERENCES "asset_faces" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "memories" ADD CONSTRAINT "FK_575842846f0c28fa5da46c99b19" FOREIGN KEY ("ownerId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_984e5c9ab1f04d34538cd32334e" FOREIGN KEY ("memoriesId") REFERENCES "memories" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "memories_assets_assets" ADD CONSTRAINT "FK_6942ecf52d75d4273de19d2c16f" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "partners" ADD CONSTRAINT "FK_7e077a8b70b3530138610ff5e04" FOREIGN KEY ("sharedById") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "partners" ADD CONSTRAINT "FK_d7e875c6c60e661723dbf372fd3" FOREIGN KEY ("sharedWithId") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "sessions" ADD CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_66fe3837414c5a9f1c33ca49340" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "smart_search" ADD CONSTRAINT "smart_search_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "assets" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "session_sync_checkpoints" ADD CONSTRAINT "FK_d8ddd9d687816cc490432b3d4bc" FOREIGN KEY ("sessionId") REFERENCES "sessions" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "user_metadata" ADD CONSTRAINT "FK_6afb43681a21cf7815932bc38ac" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); await sql`ALTER TABLE "users" ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email");`.execute(db); await sql`ALTER TABLE "users" ADD CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a" UNIQUE ("storageLabel");`.execute(db); - await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "REL_91704e101438fd0653f582426d" UNIQUE ("primaryAssetId");`.execute(db); + await sql`ALTER TABLE "asset_stack" ADD CONSTRAINT "REL_91704e101438fd0653f582426d" UNIQUE ("primaryAssetId");`.execute( + db, + ); await sql`ALTER TABLE "asset_files" ADD CONSTRAINT "UQ_assetId_type" UNIQUE ("assetId", "type");`.execute(db); await sql`ALTER TABLE "move_history" ADD CONSTRAINT "UQ_newPath" UNIQUE ("newPath");`.execute(db); - await sql`ALTER TABLE "move_history" ADD CONSTRAINT "UQ_entityId_pathType" UNIQUE ("entityId", "pathType");`.execute(db); + await sql`ALTER TABLE "move_history" ADD CONSTRAINT "UQ_entityId_pathType" UNIQUE ("entityId", "pathType");`.execute( + db, + ); await sql`ALTER TABLE "shared_links" ADD CONSTRAINT "UQ_sharedlink_key" UNIQUE ("key");`.execute(db); await sql`ALTER TABLE "tags" ADD CONSTRAINT "UQ_79d6f16e52bb2c7130375246793" UNIQUE ("userId", "value");`.execute(db); - await sql`ALTER TABLE "activity" ADD CONSTRAINT "CHK_2ab1e70f113f450eb40c1e3ec8" CHECK (("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false));`.execute(db); - await sql`ALTER TABLE "person" ADD CONSTRAINT "CHK_b0f82b0ed662bfc24fbb58bb45" CHECK ("birthDate" <= CURRENT_DATE);`.execute(db); + await sql`ALTER TABLE "activity" ADD CONSTRAINT "CHK_2ab1e70f113f450eb40c1e3ec8" CHECK (("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false));`.execute( + db, + ); + await sql`ALTER TABLE "person" ADD CONSTRAINT "CHK_b0f82b0ed662bfc24fbb58bb45" CHECK ("birthDate" <= CURRENT_DATE);`.execute( + db, + ); await sql`CREATE INDEX "IDX_users_updated_at_asc_id_asc" ON "users" ("updatedAt", "id")`.execute(db); await sql`CREATE INDEX "IDX_users_update_id" ON "users" ("updateId")`.execute(db); await sql`CREATE INDEX "IDX_0f6fc2fb195f24d19b0fb0d57c" ON "libraries" ("ownerId")`.execute(db); await sql`CREATE INDEX "IDX_libraries_update_id" ON "libraries" ("updateId")`.execute(db); await sql`CREATE INDEX "IDX_91704e101438fd0653f582426d" ON "asset_stack" ("primaryAssetId")`.execute(db); await sql`CREATE INDEX "IDX_c05079e542fd74de3b5ecb5c1c" ON "asset_stack" ("ownerId")`.execute(db); - await sql`CREATE INDEX "idx_originalfilename_trigram" ON "assets" USING gin (f_unaccent("originalFileName") gin_trgm_ops)`.execute(db); + await sql`CREATE INDEX "idx_originalfilename_trigram" ON "assets" USING gin (f_unaccent("originalFileName") gin_trgm_ops)`.execute( + db, + ); await sql`CREATE INDEX "IDX_asset_id_stackId" ON "assets" ("id", "stackId")`.execute(db); await sql`CREATE INDEX "IDX_originalPath_libraryId" ON "assets" ("originalPath", "libraryId")`.execute(db); - await sql`CREATE INDEX "idx_local_date_time_month" ON "assets" ((date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text))`.execute(db); + await sql`CREATE INDEX "idx_local_date_time_month" ON "assets" ((date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text))`.execute( + db, + ); await sql`CREATE INDEX "idx_local_date_time" ON "assets" ((("localDateTime" at time zone 'UTC')::date))`.execute(db); - await sql`CREATE UNIQUE INDEX "UQ_assets_owner_library_checksum" ON "assets" ("ownerId", "libraryId", "checksum") WHERE ("libraryId" IS NOT NULL)`.execute(db); - await sql`CREATE UNIQUE INDEX "UQ_assets_owner_checksum" ON "assets" ("ownerId", "checksum") WHERE ("libraryId" IS NULL)`.execute(db); + await sql`CREATE UNIQUE INDEX "UQ_assets_owner_library_checksum" ON "assets" ("ownerId", "libraryId", "checksum") WHERE ("libraryId" IS NOT NULL)`.execute( + db, + ); + await sql`CREATE UNIQUE INDEX "UQ_assets_owner_checksum" ON "assets" ("ownerId", "checksum") WHERE ("libraryId" IS NULL)`.execute( + db, + ); await sql`CREATE INDEX "IDX_2c5ac0d6fb58b238fd2068de67" ON "assets" ("ownerId")`.execute(db); await sql`CREATE INDEX "idx_asset_file_created_at" ON "assets" ("fileCreatedAt")`.execute(db); await sql`CREATE INDEX "IDX_8d3efe36c0755849395e6ea866" ON "assets" ("checksum")`.execute(db); @@ -266,7 +456,9 @@ export async function up(db: Kysely): Promise { await sql`CREATE INDEX "IDX_b22c53f35ef20c28c21637c85f" ON "albums" ("ownerId")`.execute(db); await sql`CREATE INDEX "IDX_05895aa505a670300d4816debc" ON "albums" ("albumThumbnailAssetId")`.execute(db); await sql`CREATE INDEX "IDX_albums_update_id" ON "albums" ("updateId")`.execute(db); - await sql`CREATE UNIQUE INDEX "IDX_activity_like" ON "activity" ("assetId", "userId", "albumId") WHERE ("isLiked" = true)`.execute(db); + await sql`CREATE UNIQUE INDEX "IDX_activity_like" ON "activity" ("assetId", "userId", "albumId") WHERE ("isLiked" = true)`.execute( + db, + ); await sql`CREATE INDEX "IDX_1af8519996fbfb3684b58df280" ON "activity" ("albumId")`.execute(db); await sql`CREATE INDEX "IDX_3571467bcbe021f66e2bdce96e" ON "activity" ("userId")`.execute(db); await sql`CREATE INDEX "IDX_8091ea76b12338cb4428d33d78" ON "activity" ("assetId")`.execute(db); @@ -295,11 +487,21 @@ export async function up(db: Kysely): Promise { await sql`CREATE INDEX "IDX_auto_stack_id" ON "exif" ("autoStackId")`.execute(db); await sql`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId")`.execute(db); await sql.raw(vectorIndexQuery({ vectorExtension, table: 'face_search', indexName: 'face_index' })).execute(db); - await sql`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" (ll_to_earth_public(latitude, longitude))`.execute(db); - await sql`CREATE INDEX "idx_geodata_places_name" ON "geodata_places" USING gin (f_unaccent("name") gin_trgm_ops)`.execute(db); - await sql`CREATE INDEX "idx_geodata_places_admin2_name" ON "geodata_places" USING gin (f_unaccent("admin2Name") gin_trgm_ops)`.execute(db); - await sql`CREATE INDEX "idx_geodata_places_admin1_name" ON "geodata_places" USING gin (f_unaccent("admin1Name") gin_trgm_ops)`.execute(db); - await sql`CREATE INDEX "idx_geodata_places_alternate_names" ON "geodata_places" USING gin (f_unaccent("alternateNames") gin_trgm_ops)`.execute(db); + await sql`CREATE INDEX "IDX_geodata_gist_earthcoord" ON "geodata_places" (ll_to_earth_public(latitude, longitude))`.execute( + db, + ); + await sql`CREATE INDEX "idx_geodata_places_name" ON "geodata_places" USING gin (f_unaccent("name") gin_trgm_ops)`.execute( + db, + ); + await sql`CREATE INDEX "idx_geodata_places_admin2_name" ON "geodata_places" USING gin (f_unaccent("admin2Name") gin_trgm_ops)`.execute( + db, + ); + await sql`CREATE INDEX "idx_geodata_places_admin1_name" ON "geodata_places" USING gin (f_unaccent("admin1Name") gin_trgm_ops)`.execute( + db, + ); + await sql`CREATE INDEX "idx_geodata_places_alternate_names" ON "geodata_places" USING gin (f_unaccent("alternateNames") gin_trgm_ops)`.execute( + db, + ); await sql`CREATE INDEX "IDX_575842846f0c28fa5da46c99b1" ON "memories" ("ownerId")`.execute(db); await sql`CREATE INDEX "IDX_memories_update_id" ON "memories" ("updateId")`.execute(db); await sql`CREATE INDEX "IDX_984e5c9ab1f04d34538cd32334" ON "memories_assets_assets" ("memoriesId")`.execute(db); @@ -319,7 +521,9 @@ export async function up(db: Kysely): Promise { await sql`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId")`.execute(db); await sql.raw(vectorIndexQuery({ vectorExtension, table: 'smart_search', indexName: 'clip_index' })).execute(db); await sql`CREATE INDEX "IDX_d8ddd9d687816cc490432b3d4b" ON "session_sync_checkpoints" ("sessionId")`.execute(db); - await sql`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`.execute(db); + await sql`CREATE INDEX "IDX_session_sync_checkpoints_update_id" ON "session_sync_checkpoints" ("updateId")`.execute( + db, + ); await sql`CREATE INDEX "IDX_92e67dc508c705dd66c9461557" ON "tags" ("userId")`.execute(db); await sql`CREATE INDEX "IDX_9f9590cc11561f1f48ff034ef9" ON "tags" ("parentId")`.execute(db); await sql`CREATE INDEX "IDX_tags_update_id" ON "tags" ("updateId")`.execute(db); @@ -407,5 +611,5 @@ export async function up(db: Kysely): Promise { } export async function down(): Promise { -// not implemented + // not implemented } diff --git a/server/src/schema/migrations/1749067526135-UserOnboardingDefault.ts b/server/src/schema/migrations/1749067526135-UserOnboardingDefault.ts index 376541410f..e6aabec27d 100644 --- a/server/src/schema/migrations/1749067526135-UserOnboardingDefault.ts +++ b/server/src/schema/migrations/1749067526135-UserOnboardingDefault.ts @@ -2,11 +2,11 @@ import { Kysely, sql } from 'kysely'; import { UserMetadataKey } from 'src/enum'; export async function up(db: Kysely): Promise { - await sql`INSERT INTO user_metadata SELECT id, ${UserMetadataKey.ONBOARDING}, '{"isOnboarded": true}' FROM users + await sql`INSERT INTO user_metadata SELECT id, ${UserMetadataKey.Onboarding}, '{"isOnboarded": true}' FROM users ON CONFLICT ("userId", key) DO NOTHING `.execute(db); } export async function down(db: Kysely): Promise { - await sql`DELETE FROM user_metadata WHERE key = ${UserMetadataKey.ONBOARDING}`.execute(db); + await sql`DELETE FROM user_metadata WHERE key = ${UserMetadataKey.Onboarding}`.execute(db); } diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 6f20e25d90..94383218da 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -47,7 +47,7 @@ export class AlbumUserTable { }) usersId!: string; - @Column({ type: 'character varying', default: AlbumUserRole.EDITOR }) + @Column({ type: 'character varying', default: AlbumUserRole.Editor }) role!: Generated; @CreateIdColumn({ index: true }) diff --git a/server/src/schema/tables/album.table.ts b/server/src/schema/tables/album.table.ts index bca15d520b..5628db3d03 100644 --- a/server/src/schema/tables/album.table.ts +++ b/server/src/schema/tables/album.table.ts @@ -57,7 +57,7 @@ export class AlbumTable { @Column({ type: 'boolean', default: true }) isActivityEnabled!: Generated; - @Column({ default: AssetOrder.DESC }) + @Column({ default: AssetOrder.Desc }) order!: Generated; @UpdateIdColumn({ index: true }) diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 483d655768..6e45a3a64d 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -56,7 +56,7 @@ export class AssetFaceTable { @Column({ default: 0, type: 'integer' }) boundingBoxY2!: Generated; - @Column({ default: SourceType.MACHINE_LEARNING, enum: asset_face_source_type }) + @Column({ default: SourceType.MachineLearning, enum: asset_face_source_type }) sourceType!: Generated; @DeleteDateColumn() diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 4e1d073848..e92e01a1bd 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -132,12 +132,12 @@ export class AssetTable { @Column({ type: 'uuid', nullable: true, index: true }) duplicateId!: string | null; - @Column({ enum: assets_status_enum, default: AssetStatus.ACTIVE }) + @Column({ enum: assets_status_enum, default: AssetStatus.Active }) status!: Generated; @UpdateIdColumn({ index: true }) updateId!: Generated; - @Column({ enum: asset_visibility_enum, default: AssetVisibility.TIMELINE }) + @Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline }) visibility!: Generated; } diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 97ac0ff295..46d6656382 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -73,7 +73,7 @@ export class UserTable { @Column({ type: 'bigint', default: 0 }) quotaUsageInBytes!: Generated>; - @Column({ type: 'character varying', default: UserStatus.ACTIVE }) + @Column({ type: 'character varying', default: UserStatus.Active }) status!: Generated; @Column({ type: 'timestamp with time zone', default: () => 'now()' }) diff --git a/server/src/services/activity.service.ts b/server/src/services/activity.service.ts index 8256a34f02..b1c25f8286 100644 --- a/server/src/services/activity.service.ts +++ b/server/src/services/activity.service.ts @@ -18,7 +18,7 @@ import { BaseService } from 'src/services/base.service'; @Injectable() export class ActivityService extends BaseService { async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [dto.albumId] }); const activities = await this.activityRepository.search({ userId: dto.userId, albumId: dto.albumId, @@ -30,12 +30,12 @@ export class ActivityService extends BaseService { } async getStatistics(auth: AuthDto, dto: ActivityDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [dto.albumId] }); return await this.activityRepository.getStatistics({ albumId: dto.albumId, assetId: dto.assetId }); } async create(auth: AuthDto, dto: ActivityCreateDto): Promise> { - await this.requireAccess({ auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.ActivityCreate, ids: [dto.albumId] }); const common = { userId: auth.user.id, @@ -69,7 +69,7 @@ export class ActivityService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.ACTIVITY_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.ActivityDelete, ids: [id] }); await this.activityRepository.delete(id); } } diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index cdace249c0..6f07a31dd9 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -146,7 +146,7 @@ describe(AlbumService.name, () => { await sut.create(authStub.admin, { albumName: 'Empty album', - albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + albumUsers: [{ userId: 'user-id', role: AlbumUserRole.Editor }], description: '', assetIds: ['123'], }); @@ -160,7 +160,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: '123', }, ['123'], - [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + [{ userId: 'user-id', role: AlbumUserRole.Editor }], ); expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); @@ -177,10 +177,10 @@ describe(AlbumService.name, () => { mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.getMetadata.mockResolvedValue([ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { albums: { - defaultAssetOrder: AssetOrder.ASC, + defaultAssetOrder: AssetOrder.Asc, }, }, }, @@ -189,7 +189,7 @@ describe(AlbumService.name, () => { await sut.create(authStub.admin, { albumName: 'Empty album', - albumUsers: [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + albumUsers: [{ userId: 'user-id', role: AlbumUserRole.Editor }], description: '', assetIds: ['123'], }); @@ -203,7 +203,7 @@ describe(AlbumService.name, () => { albumThumbnailAssetId: '123', }, ['123'], - [{ userId: 'user-id', role: AlbumUserRole.EDITOR }], + [{ userId: 'user-id', role: AlbumUserRole.Editor }], ); expect(mocks.user.get).toHaveBeenCalledWith('user-id', {}); @@ -220,7 +220,7 @@ describe(AlbumService.name, () => { await expect( sut.create(authStub.admin, { albumName: 'Empty album', - albumUsers: [{ userId: 'user-3', role: AlbumUserRole.EDITOR }], + albumUsers: [{ userId: 'user-3', role: AlbumUserRole.Editor }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.user.get).toHaveBeenCalledWith('user-3', {}); @@ -262,7 +262,7 @@ describe(AlbumService.name, () => { await expect( sut.create(authStub.admin, { albumName: 'Empty album', - albumUsers: [{ userId: userStub.admin.id, role: AlbumUserRole.EDITOR }], + albumUsers: [{ userId: userStub.admin.id, role: AlbumUserRole.Editor }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.create).not.toHaveBeenCalled(); @@ -404,7 +404,7 @@ describe(AlbumService.name, () => { mocks.albumUser.create.mockResolvedValue({ usersId: userStub.user2.id, albumsId: albumStub.sharedWithAdmin.id, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }); await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: authStub.user2.user.id }], @@ -512,11 +512,11 @@ describe(AlbumService.name, () => { mocks.albumUser.update.mockResolvedValue(null as any); await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, { - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }); expect(mocks.albumUser.update).toHaveBeenCalledWith( { albumsId: albumStub.sharedWithAdmin.id, usersId: userStub.admin.id }, - { role: AlbumUserRole.EDITOR }, + { role: AlbumUserRole.Editor }, ); }); }); @@ -585,7 +585,7 @@ describe(AlbumService.name, () => { expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.user1.user.id, new Set(['album-123']), - AlbumUserRole.VIEWER, + AlbumUserRole.Viewer, ); }); @@ -596,7 +596,7 @@ describe(AlbumService.name, () => { expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set(['album-123']), - AlbumUserRole.VIEWER, + AlbumUserRole.Viewer, ); }); }); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 9008685e81..e4f2a44bfd 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -71,7 +71,7 @@ export class AlbumService extends BaseService { } async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); await this.albumRepository.updateThumbnails(); const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets; const album = await this.findOrFail(id, { withAssets }); @@ -102,7 +102,7 @@ export class AlbumService extends BaseService { const allowedAssetIdsSet = await this.checkAccess({ auth, - permission: Permission.ASSET_SHARE, + permission: Permission.AssetShare, ids: dto.assetIds || [], }); const assetIds = [...allowedAssetIdsSet].map((id) => id); @@ -129,7 +129,7 @@ export class AlbumService extends BaseService { } async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumUpdate, ids: [id] }); const album = await this.findOrFail(id, { withAssets: true }); @@ -152,13 +152,13 @@ export class AlbumService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumDelete, ids: [id] }); await this.albumRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { const album = await this.findOrFail(id, { withAssets: false }); - await this.requireAccess({ auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumAddAsset, ids: [id] }); const results = await addAssets( auth, @@ -187,13 +187,13 @@ export class AlbumService extends BaseService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumRemoveAsset, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); const results = await removeAssets( auth, { access: this.accessRepository, bulk: this.albumRepository }, - { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE }, + { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.AlbumDelete }, ); const removedIds = results.filter(({ success }) => success).map(({ id }) => id); @@ -205,7 +205,7 @@ export class AlbumService extends BaseService { } async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); const album = await this.findOrFail(id, { withAssets: false }); @@ -249,14 +249,14 @@ export class AlbumService extends BaseService { // non-admin can remove themselves if (auth.user.id !== userId) { - await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); } await this.albumUserRepository.delete({ albumsId: id, usersId: userId }); } async updateUser(auth: AuthDto, id: string, userId: string, dto: UpdateAlbumUserDto): Promise { - await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [id] }); await this.albumUserRepository.update({ albumsId: id, usersId: userId }, { role: dto.role }); } diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 3448b4330f..fffe7bb536 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -15,7 +15,7 @@ describe(ApiKeyService.name, () => { describe('create', () => { it('should create a new key', async () => { const auth = factory.auth(); - const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] }); + const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] }); const key = 'super-secret'; mocks.crypto.randomBytesAsText.mockReturnValue(key); @@ -41,12 +41,12 @@ describe(ApiKeyService.name, () => { mocks.crypto.randomBytesAsText.mockReturnValue(key); mocks.apiKey.create.mockResolvedValue(apiKey); - await sut.create(auth, { permissions: [Permission.ALL] }); + await sut.create(auth, { permissions: [Permission.All] }); expect(mocks.apiKey.create).toHaveBeenCalledWith({ key: 'super-secret (hashed)', name: 'API Key', - permissions: [Permission.ALL], + permissions: [Permission.All], userId: auth.user.id, }); expect(mocks.crypto.randomBytesAsText).toHaveBeenCalled(); @@ -54,9 +54,9 @@ describe(ApiKeyService.name, () => { }); it('should throw an error if the api key does not have sufficient permissions', async () => { - const auth = factory.auth({ apiKey: { permissions: [Permission.ASSET_READ] } }); + const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } }); - await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf( + await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf( BadRequestException, ); }); @@ -69,7 +69,7 @@ describe(ApiKeyService.name, () => { mocks.apiKey.getById.mockResolvedValue(void 0); - await expect(sut.update(auth, id, { name: 'New Name', permissions: [Permission.ALL] })).rejects.toBeInstanceOf( + await expect(sut.update(auth, id, { name: 'New Name', permissions: [Permission.All] })).rejects.toBeInstanceOf( BadRequestException, ); @@ -84,18 +84,18 @@ describe(ApiKeyService.name, () => { mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); - await sut.update(auth, apiKey.id, { name: newName, permissions: [Permission.ALL] }); + await sut.update(auth, apiKey.id, { name: newName, permissions: [Permission.All] }); expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { name: newName, - permissions: [Permission.ALL], + permissions: [Permission.All], }); }); it('should update permissions', async () => { const auth = factory.auth(); const apiKey = factory.apiKey({ userId: auth.user.id }); - const newPermissions = [Permission.ACTIVITY_CREATE, Permission.ACTIVITY_READ, Permission.ACTIVITY_UPDATE]; + const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate]; mocks.apiKey.getById.mockResolvedValue(apiKey); mocks.apiKey.update.mockResolvedValue(apiKey); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index bb8f7115b8..c881ebb497 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -157,7 +157,7 @@ const assetEntity = Object.freeze({ ownerId: 'user_id_1', deviceAssetId: 'device_asset_id_1', deviceId: 'device_id_1', - type: AssetType.VIDEO, + type: AssetType.Video, originalPath: 'fake_path/asset_1.jpeg', fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), @@ -177,7 +177,7 @@ const assetEntity = Object.freeze({ const existingAsset = Object.freeze({ ...assetEntity, duration: null, - type: AssetType.IMAGE, + type: AssetType.Image, checksum: Buffer.from('_getExistingAsset', 'utf8'), libraryId: 'libraryId', originalFileName: 'existing-filename.jpeg', @@ -384,7 +384,7 @@ describe(AssetMediaService.name, () => { }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); @@ -409,7 +409,7 @@ describe(AssetMediaService.name, () => { ); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); @@ -437,7 +437,7 @@ describe(AssetMediaService.name, () => { it('should hide the linked motion asset', async () => { mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); @@ -455,7 +455,7 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, }); }); @@ -506,7 +506,7 @@ describe(AssetMediaService.name, () => { new ImmichFileResponse({ path: '/original/path.jpg', contentType: 'image/jpeg', - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, }), ); }); @@ -546,7 +546,7 @@ describe(AssetMediaService.name, () => { { id: '42', path: '/path/to/preview', - type: AssetFileType.THUMBNAIL, + type: AssetFileType.Thumbnail, }, ], }); @@ -563,7 +563,7 @@ describe(AssetMediaService.name, () => { { id: '42', path: '/path/to/preview.jpg', - type: AssetFileType.PREVIEW, + type: AssetFileType.Preview, }, ], }); @@ -573,7 +573,7 @@ describe(AssetMediaService.name, () => { ).resolves.toEqual( new ImmichFileResponse({ path: '/path/to/preview.jpg', - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', fileName: 'asset-id_thumbnail.jpg', }), @@ -588,7 +588,7 @@ describe(AssetMediaService.name, () => { ).resolves.toEqual( new ImmichFileResponse({ path: '/uploads/user-id/thumbs/path.jpg', - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, contentType: 'image/jpeg', fileName: 'asset-id_preview.jpg', }), @@ -603,7 +603,7 @@ describe(AssetMediaService.name, () => { ).resolves.toEqual( new ImmichFileResponse({ path: '/uploads/user-id/webp/path.ext', - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, contentType: 'application/octet-stream', fileName: 'asset-id_thumbnail.ext', }), @@ -640,7 +640,7 @@ describe(AssetMediaService.name, () => { await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual( new ImmichFileResponse({ path: assetStub.hasEncodedVideo.encodedVideoPath!, - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, contentType: 'video/mp4', }), ); @@ -653,7 +653,7 @@ describe(AssetMediaService.name, () => { await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( new ImmichFileResponse({ path: assetStub.video.originalPath, - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, contentType: 'application/octet-stream', }), ); @@ -723,7 +723,7 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), - status: AssetStatus.TRASHED, + status: AssetStatus.Trashed, }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(mocks.storage.utimes).toHaveBeenCalledWith( @@ -754,7 +754,7 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), - status: AssetStatus.TRASHED, + status: AssetStatus.Trashed, }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(mocks.storage.utimes).toHaveBeenCalledWith( @@ -783,7 +783,7 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], { deletedAt: expect.any(Date), - status: AssetStatus.TRASHED, + status: AssetStatus.Trashed, }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size); expect(mocks.storage.utimes).toHaveBeenCalledWith( @@ -815,7 +815,7 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.create).not.toHaveBeenCalled(); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: [updatedFile.originalPath, undefined] }, }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); @@ -912,7 +912,7 @@ describe(AssetMediaService.name, () => { await sut.onUploadError(request, file); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, }); }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 6fc438481d..127fdbe8eb 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -106,9 +106,9 @@ export class AssetMediaService extends BaseService { getUploadFolder({ auth, fieldName, file }: UploadRequest): string { auth = requireUploadAccess(auth); - let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid); + let folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, file.uuid); if (fieldName === UploadFieldName.PROFILE_DATA) { - folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id); + folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id); } this.storageRepository.mkdirSync(folder); @@ -121,7 +121,7 @@ export class AssetMediaService extends BaseService { const uploadFolder = this.getUploadFolder(asRequest(request, file)); const uploadPath = `${uploadFolder}/${uploadFilename}`; - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [uploadPath] } }); + await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [uploadPath] } }); } async uploadAsset( @@ -133,7 +133,7 @@ export class AssetMediaService extends BaseService { try { await this.requireAccess({ auth, - permission: Permission.ASSET_UPLOAD, + permission: Permission.AssetUpload, // do not need an id here, but the interface requires it ids: [auth.user.id], }); @@ -164,7 +164,7 @@ export class AssetMediaService extends BaseService { sidecarFile?: UploadFile, ): Promise { try { - await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); const asset = await this.assetRepository.getById(id); if (!asset) { @@ -179,7 +179,7 @@ export class AssetMediaService extends BaseService { // but the local variable holds the original file data paths. const copiedPhoto = await this.createCopy(asset); // and immediate trash it - await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.TRASHED }); + await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed }); await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id }); await this.userRepository.updateUsage(auth.user.id, file.size); @@ -191,14 +191,14 @@ export class AssetMediaService extends BaseService { } async downloadOriginal(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] }); const asset = await this.findOrFail(id); return new ImmichFileResponse({ path: asset.originalPath, contentType: mimeTypes.lookup(asset.originalPath), - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, }); } @@ -207,7 +207,7 @@ export class AssetMediaService extends BaseService { id: string, dto: AssetMediaOptionsDto, ): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); const asset = await this.findOrFail(id); const size = dto.size ?? AssetMediaSize.THUMBNAIL; @@ -240,16 +240,16 @@ export class AssetMediaService extends BaseService { fileName, path: filepath, contentType: mimeTypes.lookup(filepath), - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, }); } async playbackVideo(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_VIEW, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); const asset = await this.findOrFail(id); - if (asset.type !== AssetType.VIDEO) { + if (asset.type !== AssetType.Video) { throw new BadRequestException('Asset is not a video'); } @@ -258,7 +258,7 @@ export class AssetMediaService extends BaseService { return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), - cacheControl: CacheControl.PRIVATE_WITH_CACHE, + cacheControl: CacheControl.PrivateWithCache, }); } @@ -312,7 +312,7 @@ export class AssetMediaService extends BaseService { ): Promise { // clean up files await this.jobRepository.queue({ - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: [file.originalPath, sidecarFile?.originalPath] }, }); @@ -365,7 +365,7 @@ export class AssetMediaService extends BaseService { await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }); await this.jobRepository.queue({ - name: JobName.METADATA_EXTRACTION, + name: JobName.MetadataExtraction, data: { id: assetId, source: 'upload' }, }); } @@ -394,7 +394,7 @@ export class AssetMediaService extends BaseService { const { size } = await this.storageRepository.stat(created.originalPath); await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size }); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: created.id, source: 'copy' } }); + await this.jobRepository.queue({ name: JobName.MetadataExtraction, data: { id: created.id, source: 'copy' } }); return created; } @@ -416,7 +416,7 @@ export class AssetMediaService extends BaseService { type: mimeTypes.assetType(file.originalPath), isFavorite: dto.isFavorite, duration: dto.duration || null, - visibility: dto.visibility ?? AssetVisibility.TIMELINE, + visibility: dto.visibility ?? AssetVisibility.Timeline, livePhotoVideoId: dto.livePhotoVideoId, originalFileName: dto.filename || file.originalName, sidecarPath: sidecarFile?.originalPath, @@ -427,7 +427,7 @@ export class AssetMediaService extends BaseService { } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); + await this.jobRepository.queue({ name: JobName.MetadataExtraction, data: { id: asset.id, source: 'upload' } }); return asset; } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 65b14ca2da..606c93eab8 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -13,10 +13,10 @@ import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; const stats: AssetStats = { - [AssetType.IMAGE]: 10, - [AssetType.VIDEO]: 23, - [AssetType.AUDIO]: 0, - [AssetType.OTHER]: 0, + [AssetType.Image]: 10, + [AssetType.Video]: 23, + [AssetType.Audio]: 0, + [AssetType.Other]: 0, }; const statResponse: AssetStatsResponseDto = { @@ -46,21 +46,21 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.TIMELINE })).resolves.toEqual( + await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Timeline })).resolves.toEqual( statResponse, ); expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); }); it('should get the statistics for a user for archived assets', async () => { mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.ARCHIVE })).resolves.toEqual( + await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Archive })).resolves.toEqual( statResponse, ); expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { - visibility: AssetVisibility.ARCHIVE, + visibility: AssetVisibility.Archive, }); }); @@ -202,7 +202,7 @@ describe(AssetService.name, () => { describe('update', () => { it('should require asset write access for the id', async () => { await expect( - sut.update(authStub.admin, 'asset-1', { visibility: AssetVisibility.TIMELINE }), + sut.update(authStub.admin, 'asset-1', { visibility: AssetVisibility.Timeline }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.asset.update).not.toHaveBeenCalled(); @@ -253,7 +253,7 @@ describe(AssetService.name, () => { }); expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, @@ -277,7 +277,7 @@ describe(AssetService.name, () => { }); expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, @@ -301,7 +301,7 @@ describe(AssetService.name, () => { }); expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, @@ -314,7 +314,7 @@ describe(AssetService.name, () => { mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, ownerId: authStub.admin.user.id, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); mocks.asset.getById.mockResolvedValueOnce(assetStub.image); mocks.asset.update.mockResolvedValue(assetStub.image); @@ -325,7 +325,7 @@ describe(AssetService.name, () => { expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: assetStub.livePhotoMotionAsset.id, @@ -392,10 +392,10 @@ describe(AssetService.name, () => { it('should update all assets', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.ARCHIVE }); + await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive }); expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { - visibility: AssetVisibility.ARCHIVE, + visibility: AssetVisibility.Archive, }); }); @@ -428,7 +428,7 @@ describe(AssetService.name, () => { expect(mocks.asset.updateAll).toHaveBeenCalled(); expect(mocks.asset.updateAllExif).toHaveBeenCalledWith(['asset-1'], { latitude: 0, longitude: 0 }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SIDECAR_WRITE, data: { id: 'asset-1', latitude: 0, longitude: 0 } }, + { name: JobName.SidecarWrite, data: { id: 'asset-1', latitude: 0, longitude: 0 } }, ]); }); @@ -451,7 +451,7 @@ describe(AssetService.name, () => { longitude: 50, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SIDECAR_WRITE, data: { id: 'asset-1', dateTimeOriginal, latitude: 30, longitude: 50 } }, + { name: JobName.SidecarWrite, data: { id: 'asset-1', dateTimeOriginal, latitude: 30, longitude: 50 } }, ]); }); @@ -497,7 +497,7 @@ describe(AssetService.name, () => { expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], { deletedAt: expect.any(Date), - status: AssetStatus.TRASHED, + status: AssetStatus.Trashed, }); expect(mocks.job.queue.mock.calls).toEqual([]); }); @@ -518,11 +518,11 @@ describe(AssetService.name, () => { mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } }); - await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForDeletedJob).toHaveBeenCalledWith(new Date()); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: asset.id, deleteOnDisk: true } }, + { name: JobName.AssetDeletion, data: { id: asset.id, deleteOnDisk: true } }, ]); }); @@ -532,11 +532,11 @@ describe(AssetService.name, () => { mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset])); mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } }); - await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAssetDeletionCheck()).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForDeletedJob).toHaveBeenCalledWith(DateTime.now().minus({ days: 7 }).toJSDate()); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION, data: { id: asset.id, deleteOnDisk: true } }, + { name: JobName.AssetDeletion, data: { id: asset.id, deleteOnDisk: true } }, ]); }); }); @@ -552,7 +552,7 @@ describe(AssetService.name, () => { expect(mocks.job.queue.mock.calls).toEqual([ [ { - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: [ '/uploads/user-id/webp/path.ext', @@ -606,7 +606,7 @@ describe(AssetService.name, () => { expect(mocks.job.queue.mock.calls).toEqual([ [ { - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id: assetStub.livePhotoMotionAsset.id, deleteOnDisk: true, @@ -615,7 +615,7 @@ describe(AssetService.name, () => { ], [ { - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: [ '/uploads/user-id/webp/path.ext', @@ -643,7 +643,7 @@ describe(AssetService.name, () => { expect(mocks.job.queue.mock.calls).toEqual([ [ { - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: [ '/uploads/user-id/webp/path.ext', @@ -668,7 +668,7 @@ describe(AssetService.name, () => { it('should fail if asset could not be found', async () => { mocks.assetJob.getForAssetDeletion.mockResolvedValue(void 0); await expect(sut.handleAssetDeletion({ id: assetStub.image.id, deleteOnDisk: true })).resolves.toBe( - JobStatus.FAILED, + JobStatus.Failed, ); }); }); @@ -679,7 +679,7 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FaceDetection, data: { id: 'asset-1' } }]); }); it('should run the refresh metadata job', async () => { @@ -687,7 +687,7 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.MetadataExtraction, data: { id: 'asset-1' } }]); }); it('should run the refresh thumbnails job', async () => { @@ -695,7 +695,7 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GenerateThumbnails, data: { id: 'asset-1' } }]); }); it('should run the transcode video', async () => { @@ -703,7 +703,7 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VideoConversation, data: { id: 'asset-1' } }]); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 351e8827dd..d9b0527d5b 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -23,7 +23,7 @@ import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUn @Injectable() export class AssetService extends BaseService { async getStatistics(auth: AuthDto, dto: AssetStatsDto) { - if (dto.visibility === AssetVisibility.LOCKED) { + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -46,7 +46,7 @@ export class AssetService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const asset = await this.assetRepository.getById(id, { exifInfo: true, @@ -78,7 +78,7 @@ export class AssetService extends BaseService { } async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto; const repos = { asset: this.assetRepository, event: this.eventRepository }; @@ -114,7 +114,7 @@ export class AssetService extends BaseService { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { const { ids, description, dateTimeOriginal, latitude, longitude, ...options } = dto; - await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids }); if ( description !== undefined || @@ -125,7 +125,7 @@ export class AssetService extends BaseService { await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude }); await this.jobRepository.queueAll( ids.map((id) => ({ - name: JobName.SIDECAR_WRITE, + name: JobName.SidecarWrite, data: { id, description, dateTimeOriginal, latitude, longitude }, })), ); @@ -139,13 +139,13 @@ export class AssetService extends BaseService { ) { await this.assetRepository.updateAll(ids, options); - if (options.visibility === AssetVisibility.LOCKED) { + if (options.visibility === AssetVisibility.Locked) { await this.albumRepository.removeAssetsFromAll(ids); } } } - @OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.AssetDeletionCheck, queue: QueueName.BackgroundTask }) async handleAssetDeletionCheck(): Promise { const config = await this.getConfig({ withCache: false }); const trashedDays = config.trash.enabled ? config.trash.days : 0; @@ -158,7 +158,7 @@ export class AssetService extends BaseService { if (chunk.length > 0) { await this.jobRepository.queueAll( chunk.map(({ id, isOffline }) => ({ - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id, deleteOnDisk: !isOffline }, })), ); @@ -176,17 +176,17 @@ export class AssetService extends BaseService { await queueChunk(); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.ASSET_DELETION, queue: QueueName.BACKGROUND_TASK }) - async handleAssetDeletion(job: JobOf): Promise { + @OnJob({ name: JobName.AssetDeletion, queue: QueueName.BackgroundTask }) + async handleAssetDeletion(job: JobOf): Promise { const { id, deleteOnDisk } = job; const asset = await this.assetJobRepository.getForAssetDeletion(id); if (!asset) { - return JobStatus.FAILED; + return JobStatus.Failed; } // Replace the parent of the stack children with a new asset @@ -215,7 +215,7 @@ export class AssetService extends BaseService { const count = await this.assetRepository.getLivePhotoCount(asset.livePhotoVideoId); if (count === 0) { await this.jobRepository.queue({ - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id: asset.livePhotoVideoId, deleteOnDisk }, }); } @@ -228,18 +228,18 @@ export class AssetService extends BaseService { files.push(asset.sidecarPath, asset.originalPath); } - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } }); + await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files } }); - return JobStatus.SUCCESS; + return JobStatus.Success; } async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise { const { ids, force } = dto; - await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.AssetDelete, ids }); await this.assetRepository.updateAll(ids, { deletedAt: new Date(), - status: force ? AssetStatus.DELETED : AssetStatus.TRASHED, + status: force ? AssetStatus.Deleted : AssetStatus.Trashed, }); await this.eventRepository.emit(force ? 'AssetDeleteAll' : 'AssetTrashAll', { assetIds: ids, @@ -248,29 +248,29 @@ export class AssetService extends BaseService { } async run(auth: AuthDto, dto: AssetJobsDto) { - await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); const jobs: JobItem[] = []; for (const id of dto.assetIds) { switch (dto.name) { case AssetJobName.REFRESH_FACES: { - jobs.push({ name: JobName.FACE_DETECTION, data: { id } }); + jobs.push({ name: JobName.FaceDetection, data: { id } }); break; } case AssetJobName.REFRESH_METADATA: { - jobs.push({ name: JobName.METADATA_EXTRACTION, data: { id } }); + jobs.push({ name: JobName.MetadataExtraction, data: { id } }); break; } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id } }); + jobs.push({ name: JobName.GenerateThumbnails, data: { id } }); break; } case AssetJobName.TRANSCODE_VIDEO: { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: { id } }); + jobs.push({ name: JobName.VideoConversation, data: { id } }); break; } } @@ -292,7 +292,7 @@ export class AssetService extends BaseService { const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined); if (Object.keys(writes).length > 0) { await this.assetRepository.upsertExif({ assetId: id, ...writes }); - await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); + await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id, ...writes } }); } } } diff --git a/server/src/services/audit.service.spec.ts b/server/src/services/audit.service.spec.ts index 381b2ec7e8..7363ea74e1 100644 --- a/server/src/services/audit.service.spec.ts +++ b/server/src/services/audit.service.spec.ts @@ -18,7 +18,7 @@ describe(AuditService.name, () => { it('should delete old audit entries', async () => { mocks.audit.removeBefore.mockResolvedValue(); - await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleCleanup()).resolves.toBe(JobStatus.Success); expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); }); diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 7c9a070dd0..eaa48de1f4 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -7,9 +7,9 @@ import { BaseService } from 'src/services/base.service'; @Injectable() export class AuditService extends BaseService { - @OnJob({ name: JobName.CLEAN_OLD_AUDIT_LOGS, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.CleanOldAuditLogs, queue: QueueName.BackgroundTask }) async handleCleanup(): Promise { await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); - return JobStatus.SUCCESS; + return JobStatus.Success; } } diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 93bd265ba0..f52fd9dd81 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -154,7 +154,7 @@ describe(AuthService.name, () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); - await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ + await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({ successful: true, redirectUri: 'http://end-session-endpoint', }); @@ -163,7 +163,7 @@ describe(AuthService.name, () => { it('should return the default redirect', async () => { const auth = factory.auth(); - await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ + await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); @@ -173,7 +173,7 @@ describe(AuthService.name, () => { const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto; mocks.session.delete.mockResolvedValue(); - await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ + await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); @@ -185,7 +185,7 @@ describe(AuthService.name, () => { it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { const auth = { user: { id: '123' } } as AuthDto; - await expect(sut.logout(auth, AuthType.OAUTH)).resolves.toEqual({ + await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); @@ -463,7 +463,7 @@ describe(AuthService.name, () => { sut.authenticate({ headers: { 'x-api-key': 'auth_token' }, queryParams: {}, - metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: Permission.ASSET_READ }, + metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test', permission: Permission.AssetRead }, }), ).rejects.toBeInstanceOf(ForbiddenException); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index a7b0cb3259..00b7c1e9aa 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -194,13 +194,13 @@ export class AuthService extends BaseService { } private async validate({ headers, queryParams }: Omit): Promise { - const shareKey = (headers[ImmichHeader.SHARED_LINK_KEY] || queryParams[ImmichQuery.SHARED_LINK_KEY]) as string; - const session = (headers[ImmichHeader.USER_TOKEN] || - headers[ImmichHeader.SESSION_TOKEN] || - queryParams[ImmichQuery.SESSION_KEY] || + const shareKey = (headers[ImmichHeader.SharedLinkKey] || queryParams[ImmichQuery.SharedLinkKey]) as string; + const session = (headers[ImmichHeader.UserToken] || + headers[ImmichHeader.SessionToken] || + queryParams[ImmichQuery.SessionKey] || this.getBearerToken(headers) || this.getCookieToken(headers)) as string; - const apiKey = (headers[ImmichHeader.API_KEY] || queryParams[ImmichQuery.API_KEY]) as string; + const apiKey = (headers[ImmichHeader.ApiKey] || queryParams[ImmichQuery.ApiKey]) as string; if (shareKey) { return this.validateSharedLink(shareKey); @@ -321,7 +321,7 @@ export class AuthService extends BaseService { const { contentType, data } = await this.oauthRepository.getProfilePicture(url); const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg'; const profileImagePath = join( - StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id), + StorageCore.getFolderLocation(StorageFolder.Profile, user.id), `${this.cryptoRepository.randomUUID()}${extensionWithDot}`, ); @@ -330,7 +330,7 @@ export class AuthService extends BaseService { await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() }); if (oldPath) { - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldPath] } }); + await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [oldPath] } }); } } catch (error: Error | any) { this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack); @@ -366,7 +366,7 @@ export class AuthService extends BaseService { } private async getLogoutEndpoint(authType: AuthType): Promise { - if (authType !== AuthType.OAUTH) { + if (authType !== AuthType.OAuth) { return LOGIN_URL; } @@ -389,17 +389,17 @@ export class AuthService extends BaseService { private getCookieToken(headers: IncomingHttpHeaders): string | null { const cookies = parse(headers.cookie || ''); - return cookies[ImmichCookie.ACCESS_TOKEN] || null; + return cookies[ImmichCookie.AccessToken] || null; } private getCookieOauthState(headers: IncomingHttpHeaders): string | null { const cookies = parse(headers.cookie || ''); - return cookies[ImmichCookie.OAUTH_STATE] || null; + return cookies[ImmichCookie.OAuthState] || null; } private getCookieCodeVerifier(headers: IncomingHttpHeaders): string | null { const cookies = parse(headers.cookie || ''); - return cookies[ImmichCookie.OAUTH_CODE_VERIFIER] || null; + return cookies[ImmichCookie.OAuthCodeVerifier] || null; } async validateSharedLink(key: string | string[]): Promise { diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts index aa72fd588a..e36f699f53 100644 --- a/server/src/services/backup.service.spec.ts +++ b/server/src/services/backup.service.spec.ts @@ -38,7 +38,7 @@ describe(BackupService.name, () => { }); it('should not initialise backup database job when running on microservices', async () => { - mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); await sut.onConfigInit({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); expect(mocks.cron.create).not.toHaveBeenCalled(); @@ -98,10 +98,10 @@ describe(BackupService.name, () => { await sut.cleanupDatabaseBackups(); expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); expect(mocks.storage.unlink).toHaveBeenCalledWith( - `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/immich-db-backup-123.sql.gz.tmp`, ); expect(mocks.storage.unlink).toHaveBeenCalledWith( - `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`, + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/immich-db-backup-345.sql.gz.tmp`, ); }); @@ -111,7 +111,7 @@ describe(BackupService.name, () => { await sut.cleanupDatabaseBackups(); expect(mocks.storage.unlink).toHaveBeenCalledTimes(1); expect(mocks.storage.unlink).toHaveBeenCalledWith( - `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`, + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/immich-db-backup-1.sql.gz`, ); }); @@ -125,10 +125,10 @@ describe(BackupService.name, () => { await sut.cleanupDatabaseBackups(); expect(mocks.storage.unlink).toHaveBeenCalledTimes(2); expect(mocks.storage.unlink).toHaveBeenCalledWith( - `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/immich-db-backup-1.sql.gz.tmp`, ); expect(mocks.storage.unlink).toHaveBeenCalledWith( - `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, + `${StorageCore.getBaseFolder(StorageFolder.Backups)}/immich-db-backup-2.sql.gz`, ); }); }); @@ -145,13 +145,13 @@ describe(BackupService.name, () => { it('should run a database backup successfully', async () => { const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.SUCCESS); + expect(result).toBe(JobStatus.Success); expect(mocks.storage.createWriteStream).toHaveBeenCalled(); }); it('should rename file on success', async () => { const result = await sut.handleBackupDatabase(); - expect(result).toBe(JobStatus.SUCCESS); + expect(result).toBe(JobStatus.Success); expect(mocks.storage.rename).toHaveBeenCalled(); }); @@ -219,7 +219,7 @@ describe(BackupService.name, () => { mocks.database.getPostgresVersion.mockResolvedValue(postgresVersion); const result = await sut.handleBackupDatabase(); expect(mocks.process.spawn).not.toHaveBeenCalled(); - expect(result).toBe(JobStatus.FAILED); + expect(result).toBe(JobStatus.Failed); }); }); }); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index 79c5deee57..a5882b4f80 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -14,7 +14,7 @@ import { handlePromiseError } from 'src/utils/misc'; export class BackupService extends BaseService { private backupLock = false; - @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.MICROSERVICES] }) + @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Microservices] }) async onConfigInit({ newConfig: { backup: { database }, @@ -26,7 +26,7 @@ export class BackupService extends BaseService { this.cronRepository.create({ name: 'backupDatabase', expression: database.cronExpression, - onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.BACKUP_DATABASE }), this.logger), + onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.BackupDatabase }), this.logger), start: database.enabled, }); } @@ -51,7 +51,7 @@ export class BackupService extends BaseService { backup: { database: config }, } = await this.getConfig({ withCache: false }); - const backupsFolder = StorageCore.getBaseFolder(StorageFolder.BACKUPS); + const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups); const files = await this.storageRepository.readdir(backupsFolder); const failedBackups = files.filter((file) => file.match(/immich-db-backup-\d+\.sql\.gz\.tmp$/)); const backups = files @@ -68,7 +68,7 @@ export class BackupService extends BaseService { this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`); } - @OnJob({ name: JobName.BACKUP_DATABASE, queue: QueueName.BACKUP_DATABASE }) + @OnJob({ name: JobName.BackupDatabase, queue: QueueName.BackupDatabase }) async handleBackupDatabase(): Promise { this.logger.debug(`Database Backup Started`); const { database } = this.configRepository.getEnv(); @@ -92,7 +92,7 @@ export class BackupService extends BaseService { databaseParams.push('--clean', '--if-exists'); const databaseVersion = await this.databaseRepository.getPostgresVersion(); const backupFilePath = path.join( - StorageCore.getBaseFolder(StorageFolder.BACKUPS), + StorageCore.getBaseFolder(StorageFolder.Backups), `immich-db-backup-${DateTime.now().toFormat("yyyyLLdd'T'HHmmss")}-v${serverVersion.toString()}-pg${databaseVersion.split(' ')[0]}.sql.gz.tmp`, ); const databaseSemver = semver.coerce(databaseVersion); @@ -100,7 +100,7 @@ export class BackupService extends BaseService { if (!databaseMajorVersion || !databaseSemver || !semver.satisfies(databaseSemver, '>=14.0.0 <18.0.0')) { this.logger.error(`Database Backup Failure: Unsupported PostgreSQL version: ${databaseVersion}`); - return JobStatus.FAILED; + return JobStatus.Failed; } this.logger.log(`Database Backup Starting. Database Version: ${databaseMajorVersion}`); @@ -179,6 +179,6 @@ export class BackupService extends BaseService { this.logger.log(`Database Backup Success`); await this.cleanupDatabaseBackups(); - return JobStatus.SUCCESS; + return JobStatus.Success; } } diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index 09b22dfd5e..b4022ee864 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -19,7 +19,7 @@ describe(DatabaseService.name, () => { ({ sut, mocks } = newTestService(DatabaseService)); extensionRange = '0.2.x'; - mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.VECTORCHORD); + mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.VectorChord); mocks.database.getExtensionVersionRange.mockReturnValue(extensionRange); versionBelowRange = '0.1.0'; @@ -28,7 +28,7 @@ describe(DatabaseService.name, () => { versionAboveRange = '0.3.0'; mocks.database.getExtensionVersions.mockResolvedValue([ { - name: DatabaseExtension.VECTORCHORD, + name: DatabaseExtension.VectorChord, installedVersion: null, availableVersion: minVersionInRange, }, @@ -49,9 +49,9 @@ describe(DatabaseService.name, () => { }); describe.each(>[ - { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, - { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, - { extension: DatabaseExtension.VECTORCHORD, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORCHORD] }, + { extension: DatabaseExtension.Vector, extensionName: EXTENSION_NAMES[DatabaseExtension.Vector] }, + { extension: DatabaseExtension.Vectors, extensionName: EXTENSION_NAMES[DatabaseExtension.Vectors] }, + { extension: DatabaseExtension.VectorChord, extensionName: EXTENSION_NAMES[DatabaseExtension.VectorChord] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { mocks.database.getExtensionVersions.mockResolvedValue([ @@ -292,8 +292,8 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([ - VectorIndex.CLIP, - VectorIndex.FACE, + VectorIndex.Clip, + VectorIndex.Face, ]); expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledTimes(1); expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1); @@ -306,8 +306,8 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).rejects.toBeDefined(); expect(mocks.database.reindexVectorsIfNeeded).toHaveBeenCalledExactlyOnceWith([ - VectorIndex.CLIP, - VectorIndex.FACE, + VectorIndex.Clip, + VectorIndex.Face, ]); expect(mocks.database.runMigrations).not.toHaveBeenCalled(); expect(mocks.logger.fatal).not.toHaveBeenCalled(); @@ -330,7 +330,7 @@ describe(DatabaseService.name, () => { database: 'immich', }, skipMigrations: true, - vectorExtension: DatabaseExtension.VECTORS, + vectorExtension: DatabaseExtension.Vectors, }, }), ); @@ -356,12 +356,12 @@ describe(DatabaseService.name, () => { it(`should drop unused extension`, async () => { mocks.database.getExtensionVersions.mockResolvedValue([ { - name: DatabaseExtension.VECTORS, + name: DatabaseExtension.Vectors, installedVersion: minVersionInRange, availableVersion: minVersionInRange, }, { - name: DatabaseExtension.VECTORCHORD, + name: DatabaseExtension.VectorChord, installedVersion: null, availableVersion: minVersionInRange, }, @@ -369,19 +369,19 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VECTORCHORD); - expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VECTORS); + expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord); + expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.Vectors); }); it(`should warn if unused extension could not be dropped`, async () => { mocks.database.getExtensionVersions.mockResolvedValue([ { - name: DatabaseExtension.VECTORS, + name: DatabaseExtension.Vectors, installedVersion: minVersionInRange, availableVersion: minVersionInRange, }, { - name: DatabaseExtension.VECTORCHORD, + name: DatabaseExtension.VectorChord, installedVersion: null, availableVersion: minVersionInRange, }, @@ -390,8 +390,8 @@ describe(DatabaseService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VECTORCHORD); - expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VECTORS); + expect(mocks.database.createExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.VectorChord); + expect(mocks.database.dropExtension).toHaveBeenCalledExactlyOnceWith(DatabaseExtension.Vectors); expect(mocks.logger.warn).toHaveBeenCalledTimes(1); expect(mocks.logger.warn.mock.calls[0][0]).toContain('DROP EXTENSION vectors'); }); @@ -399,12 +399,12 @@ describe(DatabaseService.name, () => { it(`should not try to drop pgvector when using vectorchord`, async () => { mocks.database.getExtensionVersions.mockResolvedValue([ { - name: DatabaseExtension.VECTOR, + name: DatabaseExtension.Vector, installedVersion: minVersionInRange, availableVersion: minVersionInRange, }, { - name: DatabaseExtension.VECTORCHORD, + name: DatabaseExtension.VectorChord, installedVersion: minVersionInRange, availableVersion: minVersionInRange, }, diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index fd59e3aa67..e54be28fc2 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -100,7 +100,7 @@ export class DatabaseService extends BaseService { } try { - await this.databaseRepository.reindexVectorsIfNeeded([VectorIndex.CLIP, VectorIndex.FACE]); + await this.databaseRepository.reindexVectorsIfNeeded([VectorIndex.Clip, VectorIndex.Face]); } catch (error) { this.logger.warn( 'Could not run vector reindexing checks. If the extension was updated, please restart the Postgres instance. If you are upgrading directly from a version below 1.107.2, please upgrade to 1.107.2 first.', @@ -109,7 +109,7 @@ export class DatabaseService extends BaseService { } for (const { name: dbName, installedVersion } of extensionVersions) { - const isDepended = dbName === DatabaseExtension.VECTOR && extension === DatabaseExtension.VECTORCHORD; + const isDepended = dbName === DatabaseExtension.Vector && extension === DatabaseExtension.VectorChord; if (dbName !== extension && installedVersion && !isDepended) { await this.dropExtension(dbName); } @@ -120,8 +120,8 @@ export class DatabaseService extends BaseService { await this.databaseRepository.runMigrations(); } await Promise.all([ - this.databaseRepository.prewarm(VectorIndex.CLIP), - this.databaseRepository.prewarm(VectorIndex.FACE), + this.databaseRepository.prewarm(VectorIndex.Clip), + this.databaseRepository.prewarm(VectorIndex.Face), ]); }); } diff --git a/server/src/services/download.service.ts b/server/src/services/download.service.ts index 02711b9bfd..a5f734e59c 100644 --- a/server/src/services/download.service.ts +++ b/server/src/services/download.service.ts @@ -17,15 +17,15 @@ export class DownloadService extends BaseService { if (dto.assetIds) { const assetIds = dto.assetIds; - await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds }); + await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: assetIds }); assets = this.downloadRepository.downloadAssetIds(assetIds); } else if (dto.albumId) { const albumId = dto.albumId; - await this.requireAccess({ auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] }); + await this.requireAccess({ auth, permission: Permission.AlbumDownload, ids: [albumId] }); assets = this.downloadRepository.downloadAlbumId(albumId); } else if (dto.userId) { const userId = dto.userId; - await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] }); + await this.requireAccess({ auth, permission: Permission.TimelineDownload, ids: [userId] }); assets = this.downloadRepository.downloadUserId(userId); } else { throw new BadRequestException('assetIds, albumId, or userId is required'); @@ -81,7 +81,7 @@ export class DownloadService extends BaseService { } async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds }); const zip = this.storageRepository.createZipStream(); const assets = await this.assetRepository.getByIds(dto.assetIds); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index b7d6d5fc96..0a2243e322 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -12,10 +12,10 @@ const hasEmbedding = { id: 'asset-1', ownerId: 'user-id', stackId: null, - type: AssetType.IMAGE, + type: AssetType.Image, duplicateId: null, embedding: '[1, 2, 3, 4]', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }; const hasDupe = { @@ -78,7 +78,7 @@ describe(SearchService.name, () => { }, }); - await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -94,7 +94,7 @@ describe(SearchService.name, () => { }, }); - await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueSearchDuplicates({})).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -108,7 +108,7 @@ describe(SearchService.name, () => { expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.DUPLICATE_DETECTION, + name: JobName.DuplicateDetection, data: { id: assetStub.image.id }, }, ]); @@ -122,7 +122,7 @@ describe(SearchService.name, () => { expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.DUPLICATE_DETECTION, + name: JobName.DuplicateDetection, data: { id: assetStub.image.id }, }, ]); @@ -154,7 +154,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id }); - expect(result).toBe(JobStatus.SKIPPED); + expect(result).toBe(JobStatus.Skipped); expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled(); }); @@ -171,7 +171,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id }); - expect(result).toBe(JobStatus.SKIPPED); + expect(result).toBe(JobStatus.Skipped); expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled(); }); @@ -180,7 +180,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); - expect(result).toBe(JobStatus.FAILED); + expect(result).toBe(JobStatus.Failed); expect(mocks.logger.error).toHaveBeenCalledWith(`Asset ${assetStub.image.id} not found`); }); @@ -190,7 +190,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id }); - expect(result).toBe(JobStatus.SKIPPED); + expect(result).toBe(JobStatus.Skipped); expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`); }); @@ -198,12 +198,12 @@ describe(SearchService.name, () => { const id = assetStub.livePhotoMotionAsset.id; mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, }); const result = await sut.handleSearchDuplicates({ id }); - expect(result).toBe(JobStatus.SKIPPED); + expect(result).toBe(JobStatus.Skipped); expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); }); @@ -212,7 +212,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: assetStub.image.id }); - expect(result).toBe(JobStatus.FAILED); + expect(result).toBe(JobStatus.Failed); expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${assetStub.image.id} is missing embedding`); }); @@ -226,7 +226,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id }); - expect(result).toBe(JobStatus.SUCCESS); + expect(result).toBe(JobStatus.Success); expect(mocks.duplicateRepository.search).toHaveBeenCalledWith({ assetId: hasEmbedding.id, embedding: hasEmbedding.embedding, @@ -253,7 +253,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: hasEmbedding.id }); - expect(result).toBe(JobStatus.SUCCESS); + expect(result).toBe(JobStatus.Success); expect(mocks.duplicateRepository.search).toHaveBeenCalledWith({ assetId: hasEmbedding.id, embedding: hasEmbedding.embedding, @@ -277,7 +277,7 @@ describe(SearchService.name, () => { const result = await sut.handleSearchDuplicates({ id: hasDupe.id }); - expect(result).toBe(JobStatus.SUCCESS); + expect(result).toBe(JobStatus.Success); expect(mocks.asset.update).toHaveBeenCalledWith({ id: hasDupe.id, duplicateId: null }); expect(mocks.asset.upsertJobStatus).toHaveBeenCalledWith({ assetId: hasDupe.id, diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 99674d4c36..1699cf73f6 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -29,11 +29,11 @@ export class DuplicateService extends BaseService { await this.duplicateRepository.deleteAll(auth.user.id, dto.ids); } - @OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION }) - async handleQueueSearchDuplicates({ force }: JobOf): Promise { + @OnJob({ name: JobName.QueueDuplicateDetection, queue: QueueName.DuplicateDetection }) + async handleQueueSearchDuplicates({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isDuplicateDetectionEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } let jobs: JobItem[] = []; @@ -44,7 +44,7 @@ export class DuplicateService extends BaseService { const assets = this.assetJobRepository.streamForSearchDuplicates(force); for await (const asset of assets) { - jobs.push({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } }); + jobs.push({ name: JobName.DuplicateDetection, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -52,40 +52,40 @@ export class DuplicateService extends BaseService { await queueAll(); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION }) - async handleSearchDuplicates({ id }: JobOf): Promise { + @OnJob({ name: JobName.DuplicateDetection, queue: QueueName.DuplicateDetection }) + async handleSearchDuplicates({ id }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: true }); if (!isDuplicateDetectionEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const asset = await this.assetJobRepository.getForSearchDuplicatesJob(id); if (!asset) { this.logger.error(`Asset ${id} not found`); - return JobStatus.FAILED; + return JobStatus.Failed; } if (asset.stackId) { this.logger.debug(`Asset ${id} is part of a stack, skipping`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } - if (asset.visibility === AssetVisibility.HIDDEN) { + if (asset.visibility === AssetVisibility.Hidden) { this.logger.debug(`Asset ${id} is not visible, skipping`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } - if (asset.visibility === AssetVisibility.LOCKED) { + if (asset.visibility === AssetVisibility.Locked) { this.logger.debug(`Asset ${id} is locked, skipping`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } if (!asset.embedding) { this.logger.debug(`Asset ${id} is missing embedding`); - return JobStatus.FAILED; + return JobStatus.Failed; } const duplicateAssets = await this.duplicateRepository.search({ @@ -110,7 +110,7 @@ export class DuplicateService extends BaseService { const duplicatesDetectedAt = new Date(); await this.assetRepository.upsertJobStatus(...assetIds.map((assetId) => ({ assetId, duplicatesDetectedAt }))); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async updateDuplicates( diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index a18eccdd8b..497d81b4ca 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -13,7 +13,7 @@ describe(JobService.name, () => { beforeEach(() => { ({ sut, mocks } = newTestService(JobService, {})); - mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); }); it('should work', () => { @@ -25,10 +25,10 @@ describe(JobService.name, () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(15); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); - expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.STORAGE_TEMPLATE_MIGRATION, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); + expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(9, QueueName.StorageTemplateMigration, 1); }); }); @@ -37,16 +37,16 @@ describe(JobService.name, () => { await sut.handleNightlyJobs(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.ASSET_DELETION_CHECK }, - { name: JobName.USER_DELETE_CHECK }, - { name: JobName.PERSON_CLEANUP }, - { name: JobName.MEMORIES_CLEANUP }, - { name: JobName.CLEAN_OLD_SESSION_TOKENS }, - { name: JobName.CLEAN_OLD_AUDIT_LOGS }, - { name: JobName.MEMORIES_CREATE }, - { name: JobName.USER_SYNC_USAGE }, - { name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }, - { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } }, + { name: JobName.AssetDeletionCheck }, + { name: JobName.UserDeleteCheck }, + { name: JobName.PersonCleanup }, + { name: JobName.MemoriesCleanup }, + { name: JobName.CleanOldSessionTokens }, + { name: JobName.CleanOldAuditLogs }, + { name: JobName.MemoriesCreate }, + { name: JobName.userSyncUsage }, + { name: JobName.QueueGenerateThumbnails, data: { force: false } }, + { name: JobName.QueueFacialRecognition, data: { force: false, nightly: true } }, ]); }); }); @@ -82,49 +82,49 @@ describe(JobService.name, () => { }; await expect(sut.getAllJobsStatus()).resolves.toEqual({ - [QueueName.BACKGROUND_TASK]: expectedJobStatus, - [QueueName.DUPLICATE_DETECTION]: expectedJobStatus, - [QueueName.SMART_SEARCH]: expectedJobStatus, - [QueueName.METADATA_EXTRACTION]: expectedJobStatus, - [QueueName.SEARCH]: expectedJobStatus, - [QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus, - [QueueName.MIGRATION]: expectedJobStatus, - [QueueName.THUMBNAIL_GENERATION]: expectedJobStatus, - [QueueName.VIDEO_CONVERSION]: expectedJobStatus, - [QueueName.FACE_DETECTION]: expectedJobStatus, - [QueueName.FACIAL_RECOGNITION]: expectedJobStatus, - [QueueName.SIDECAR]: expectedJobStatus, - [QueueName.LIBRARY]: expectedJobStatus, - [QueueName.NOTIFICATION]: expectedJobStatus, - [QueueName.BACKUP_DATABASE]: expectedJobStatus, + [QueueName.BackgroundTask]: expectedJobStatus, + [QueueName.DuplicateDetection]: expectedJobStatus, + [QueueName.SmartSearch]: expectedJobStatus, + [QueueName.MetadataExtraction]: expectedJobStatus, + [QueueName.Search]: expectedJobStatus, + [QueueName.StorageTemplateMigration]: expectedJobStatus, + [QueueName.Migration]: expectedJobStatus, + [QueueName.ThumbnailGeneration]: expectedJobStatus, + [QueueName.VideoConversion]: expectedJobStatus, + [QueueName.FaceDetection]: expectedJobStatus, + [QueueName.FacialRecognition]: expectedJobStatus, + [QueueName.Sidecar]: expectedJobStatus, + [QueueName.Library]: expectedJobStatus, + [QueueName.Notification]: expectedJobStatus, + [QueueName.BackupDatabase]: expectedJobStatus, }); }); }); describe('handleCommand', () => { it('should handle a pause command', async () => { - await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.PAUSE, force: false }); + await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Pause, force: false }); - expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.pause).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should handle a resume command', async () => { - await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.RESUME, force: false }); + await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Resume, force: false }); - expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.resume).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should handle an empty command', async () => { - await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.EMPTY, force: false }); + await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Empty, force: false }); - expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION); + expect(mocks.job.empty).toHaveBeenCalledWith(QueueName.MetadataExtraction); }); it('should not start a job that is already running', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: true, isPaused: false }); await expect( - sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }), + sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -134,80 +134,80 @@ describe(JobService.name, () => { it('should handle a start video conversion command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.VIDEO_CONVERSION, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueVideoConversion, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.STORAGE_TEMPLATE_MIGRATION, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.StorageTemplateMigration, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.StorageTemplateMigration }); }); it('should handle a start smart search command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.SMART_SEARCH, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.SmartSearch, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SMART_SEARCH, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueSmartSearch, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.METADATA_EXTRACTION, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueMetadataExtraction, data: { force: false } }); }); it('should handle a start sidecar command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.SIDECAR, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.Sidecar, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_SIDECAR, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueSidecar, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.THUMBNAIL_GENERATION, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.ThumbnailGeneration, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueGenerateThumbnails, data: { force: false } }); }); it('should handle a start face detection command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.FACE_DETECTION, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.FaceDetection, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACE_DETECTION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueFaceDetection, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.FACIAL_RECOGNITION, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.FacialRecognition, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueFacialRecognition, data: { force: false } }); }); it('should handle a start backup database command', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); - await sut.handleCommand(QueueName.BACKUP_DATABASE, { command: JobCommand.START, force: false }); + await sut.handleCommand(QueueName.BackupDatabase, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.BACKUP_DATABASE, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.BackupDatabase, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { mocks.job.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); await expect( - sut.handleCommand(QueueName.BACKGROUND_TASK, { command: JobCommand.START, force: false }), + sut.handleCommand(QueueName.BackgroundTask, { command: JobCommand.Start, force: false }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.job.queue).not.toHaveBeenCalled(); @@ -217,10 +217,10 @@ describe(JobService.name, () => { describe('onJobStart', () => { it('should process a successful job', async () => { - mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.Success); - await sut.onJobStart(QueueName.BACKGROUND_TASK, { - name: JobName.DELETE_FILES, + await sut.onJobStart(QueueName.BackgroundTask, { + name: JobName.DeleteFiles, data: { files: ['path/to/file'] }, }); @@ -232,55 +232,55 @@ describe(JobService.name, () => { const tests: Array<{ item: JobItem; jobs: JobName[]; stub?: any }> = [ { - item: { name: JobName.SIDECAR_SYNC, data: { id: 'asset-1' } }, - jobs: [JobName.METADATA_EXTRACTION], + item: { name: JobName.SidecarSync, data: { id: 'asset-1' } }, + jobs: [JobName.MetadataExtraction], }, { - item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } }, - jobs: [JobName.METADATA_EXTRACTION], + item: { name: JobName.SidecarDiscovery, data: { id: 'asset-1' } }, + jobs: [JobName.MetadataExtraction], }, { - item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GENERATE_THUMBNAILS], + item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.GenerateThumbnails], }, { - item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, + item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1' } }, jobs: [], }, { - item: { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'asset-1' } }, + item: { name: JobName.GeneratePersonThumbnail, data: { id: 'asset-1' } }, jobs: [], }, { - item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], stub: [assetStub.image], }, { - item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1' } }, + item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], stub: [assetStub.video], }, { - item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION], + item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SmartSearch, JobName.FaceDetection], stub: [assetStub.livePhotoStillAsset], }, { - item: { name: JobName.GENERATE_THUMBNAILS, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.SMART_SEARCH, JobName.FACE_DETECTION, JobName.VIDEO_CONVERSION], + item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SmartSearch, JobName.FaceDetection, JobName.VideoConversation], stub: [assetStub.video], }, { - item: { name: JobName.SMART_SEARCH, data: { id: 'asset-1' } }, + item: { name: JobName.SmartSearch, data: { id: 'asset-1' } }, jobs: [], }, { - item: { name: JobName.FACE_DETECTION, data: { id: 'asset-1' } }, + item: { name: JobName.FaceDetection, data: { id: 'asset-1' } }, jobs: [], }, { - item: { name: JobName.FACIAL_RECOGNITION, data: { id: 'asset-1' } }, + item: { name: JobName.FacialRecognition, data: { id: 'asset-1' } }, jobs: [], }, ]; @@ -291,9 +291,9 @@ describe(JobService.name, () => { mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue(stub); } - mocks.job.run.mockResolvedValue(JobStatus.SUCCESS); + mocks.job.run.mockResolvedValue(JobStatus.Success); - await sut.onJobStart(QueueName.BACKGROUND_TASK, item); + await sut.onJobStart(QueueName.BackgroundTask, item); if (jobs.length > 1) { expect(mocks.job.queueAll).toHaveBeenCalledWith( @@ -308,9 +308,9 @@ describe(JobService.name, () => { }); it(`should not queue any jobs when ${item.name} fails`, async () => { - mocks.job.run.mockResolvedValue(JobStatus.FAILED); + mocks.job.run.mockResolvedValue(JobStatus.Failed); - await sut.onJobStart(QueueName.BACKGROUND_TASK, item); + await sut.onJobStart(QueueName.BackgroundTask, item); expect(mocks.job.queueAll).not.toHaveBeenCalled(); }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index f0bbefc8f0..73254b9f38 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -27,28 +27,28 @@ import { handlePromiseError } from 'src/utils/misc'; const asJobItem = (dto: JobCreateDto): JobItem => { switch (dto.name) { - case ManualJobName.TAG_CLEANUP: { - return { name: JobName.TAG_CLEANUP }; + case ManualJobName.TagCleanup: { + return { name: JobName.TagCleanup }; } - case ManualJobName.PERSON_CLEANUP: { - return { name: JobName.PERSON_CLEANUP }; + case ManualJobName.PersonCleanup: { + return { name: JobName.PersonCleanup }; } - case ManualJobName.USER_CLEANUP: { - return { name: JobName.USER_DELETE_CHECK }; + case ManualJobName.UserCleanup: { + return { name: JobName.UserDeleteCheck }; } - case ManualJobName.MEMORY_CLEANUP: { - return { name: JobName.MEMORIES_CLEANUP }; + case ManualJobName.MemoryCleanup: { + return { name: JobName.MemoriesCleanup }; } - case ManualJobName.MEMORY_CREATE: { - return { name: JobName.MEMORIES_CREATE }; + case ManualJobName.MemoryCreate: { + return { name: JobName.MemoriesCreate }; } - case ManualJobName.BACKUP_DATABASE: { - return { name: JobName.BACKUP_DATABASE }; + case ManualJobName.BackupDatabase: { + return { name: JobName.BackupDatabase }; } default: { @@ -69,7 +69,7 @@ export class JobService extends BaseService { @OnEvent({ name: 'ConfigInit' }) async onConfigInit({ newConfig: config }: ArgOf<'ConfigInit'>) { - if (this.worker === ImmichWorker.MICROSERVICES) { + if (this.worker === ImmichWorker.Microservices) { this.updateQueueConcurrency(config); return; } @@ -89,7 +89,7 @@ export class JobService extends BaseService { @OnEvent({ name: 'ConfigUpdate', server: true }) onConfigUpdate({ newConfig: config }: ArgOf<'ConfigUpdate'>) { - if (this.worker === ImmichWorker.MICROSERVICES) { + if (this.worker === ImmichWorker.Microservices) { this.updateQueueConcurrency(config); return; } @@ -104,7 +104,7 @@ export class JobService extends BaseService { @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.JobService }) onBootstrap() { this.jobRepository.setup(this.services); - if (this.worker === ImmichWorker.MICROSERVICES) { + if (this.worker === ImmichWorker.Microservices) { this.jobRepository.startWorkers(); } } @@ -133,28 +133,28 @@ export class JobService extends BaseService { this.logger.debug(`Handling command: queue=${queueName},command=${dto.command},force=${dto.force}`); switch (dto.command) { - case JobCommand.START: { + case JobCommand.Start: { await this.start(queueName, dto); break; } - case JobCommand.PAUSE: { + case JobCommand.Pause: { await this.jobRepository.pause(queueName); break; } - case JobCommand.RESUME: { + case JobCommand.Resume: { await this.jobRepository.resume(queueName); break; } - case JobCommand.EMPTY: { + case JobCommand.Empty: { await this.jobRepository.empty(queueName); break; } - case JobCommand.CLEAR_FAILED: { - const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.FAILED); + case JobCommand.ClearFailed: { + const failedJobs = await this.jobRepository.clear(queueName, QueueCleanType.Failed); this.logger.debug(`Cleared failed jobs: ${failedJobs}`); break; } @@ -189,52 +189,52 @@ export class JobService extends BaseService { this.telemetryRepository.jobs.addToCounter(`immich.queues.${snakeCase(name)}.started`, 1); switch (name) { - case QueueName.VIDEO_CONVERSION: { - return this.jobRepository.queue({ name: JobName.QUEUE_VIDEO_CONVERSION, data: { force } }); + case QueueName.VideoConversion: { + return this.jobRepository.queue({ name: JobName.QueueVideoConversion, data: { force } }); } - case QueueName.STORAGE_TEMPLATE_MIGRATION: { - return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); + case QueueName.StorageTemplateMigration: { + return this.jobRepository.queue({ name: JobName.StorageTemplateMigration }); } - case QueueName.MIGRATION: { - return this.jobRepository.queue({ name: JobName.QUEUE_MIGRATION }); + case QueueName.Migration: { + return this.jobRepository.queue({ name: JobName.QueueMigration }); } - case QueueName.SMART_SEARCH: { - return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } }); + case QueueName.SmartSearch: { + return this.jobRepository.queue({ name: JobName.QueueSmartSearch, data: { force } }); } - case QueueName.DUPLICATE_DETECTION: { - return this.jobRepository.queue({ name: JobName.QUEUE_DUPLICATE_DETECTION, data: { force } }); + case QueueName.DuplicateDetection: { + return this.jobRepository.queue({ name: JobName.QueueDuplicateDetection, data: { force } }); } - case QueueName.METADATA_EXTRACTION: { - return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); + case QueueName.MetadataExtraction: { + return this.jobRepository.queue({ name: JobName.QueueMetadataExtraction, data: { force } }); } - case QueueName.SIDECAR: { - return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } }); + case QueueName.Sidecar: { + return this.jobRepository.queue({ name: JobName.QueueSidecar, data: { force } }); } - case QueueName.THUMBNAIL_GENERATION: { - return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } }); + case QueueName.ThumbnailGeneration: { + return this.jobRepository.queue({ name: JobName.QueueGenerateThumbnails, data: { force } }); } - case QueueName.FACE_DETECTION: { - return this.jobRepository.queue({ name: JobName.QUEUE_FACE_DETECTION, data: { force } }); + case QueueName.FaceDetection: { + return this.jobRepository.queue({ name: JobName.QueueFaceDetection, data: { force } }); } - case QueueName.FACIAL_RECOGNITION: { - return this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force } }); + case QueueName.FacialRecognition: { + return this.jobRepository.queue({ name: JobName.QueueFacialRecognition, data: { force } }); } - case QueueName.LIBRARY: { - return this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: { force } }); + case QueueName.Library: { + return this.jobRepository.queue({ name: JobName.LibraryQueueScanAll, data: { force } }); } - case QueueName.BACKUP_DATABASE: { - return this.jobRepository.queue({ name: JobName.BACKUP_DATABASE, data: { force } }); + case QueueName.BackupDatabase: { + return this.jobRepository.queue({ name: JobName.BackupDatabase, data: { force } }); } default: { @@ -251,7 +251,7 @@ export class JobService extends BaseService { const status = await this.jobRepository.run(job); const jobMetric = `immich.jobs.${job.name.replaceAll('-', '_')}.${status}`; this.telemetryRepository.jobs.addToCounter(jobMetric, 1); - if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) { + if (status === JobStatus.Success || status == JobStatus.Skipped) { await this.onDone(job); } } catch (error: Error | any) { @@ -263,10 +263,10 @@ export class JobService extends BaseService { private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName { return ![ - QueueName.FACIAL_RECOGNITION, - QueueName.STORAGE_TEMPLATE_MIGRATION, - QueueName.DUPLICATE_DETECTION, - QueueName.BACKUP_DATABASE, + QueueName.FacialRecognition, + QueueName.StorageTemplateMigration, + QueueName.DuplicateDetection, + QueueName.BackupDatabase, ].includes(name); } @@ -276,29 +276,29 @@ export class JobService extends BaseService { if (config.nightlyTasks.databaseCleanup) { jobs.push( - { name: JobName.ASSET_DELETION_CHECK }, - { name: JobName.USER_DELETE_CHECK }, - { name: JobName.PERSON_CLEANUP }, - { name: JobName.MEMORIES_CLEANUP }, - { name: JobName.CLEAN_OLD_SESSION_TOKENS }, - { name: JobName.CLEAN_OLD_AUDIT_LOGS }, + { name: JobName.AssetDeletionCheck }, + { name: JobName.UserDeleteCheck }, + { name: JobName.PersonCleanup }, + { name: JobName.MemoriesCleanup }, + { name: JobName.CleanOldSessionTokens }, + { name: JobName.CleanOldAuditLogs }, ); } if (config.nightlyTasks.generateMemories) { - jobs.push({ name: JobName.MEMORIES_CREATE }); + jobs.push({ name: JobName.MemoriesCreate }); } if (config.nightlyTasks.syncQuotaUsage) { - jobs.push({ name: JobName.USER_SYNC_USAGE }); + jobs.push({ name: JobName.userSyncUsage }); } if (config.nightlyTasks.missingThumbnails) { - jobs.push({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } }); + jobs.push({ name: JobName.QueueGenerateThumbnails, data: { force: false } }); } if (config.nightlyTasks.clusterNewFaces) { - jobs.push({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false, nightly: true } }); + jobs.push({ name: JobName.QueueFacialRecognition, data: { force: false, nightly: true } }); } await this.jobRepository.queueAll(jobs); @@ -309,28 +309,28 @@ export class JobService extends BaseService { */ private async onDone(item: JobItem) { switch (item.name) { - case JobName.SIDECAR_SYNC: - case JobName.SIDECAR_DISCOVERY: { - await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data }); + case JobName.SidecarSync: + case JobName.SidecarDiscovery: { + await this.jobRepository.queue({ name: JobName.MetadataExtraction, data: item.data }); break; } - case JobName.SIDECAR_WRITE: { + case JobName.SidecarWrite: { await this.jobRepository.queue({ - name: JobName.METADATA_EXTRACTION, + name: JobName.MetadataExtraction, data: { id: item.data.id, source: 'sidecar-write' }, }); break; } - case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { + case JobName.StorageTemplateMigrationSingle: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); + await this.jobRepository.queue({ name: JobName.GenerateThumbnails, data: item.data }); } break; } - case JobName.GENERATE_PERSON_THUMBNAIL: { + case JobName.GeneratePersonThumbnail: { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { @@ -339,7 +339,7 @@ export class JobService extends BaseService { break; } - case JobName.GENERATE_THUMBNAILS: { + case JobName.GenerateThumbnails: { if (!item.data.notify && item.data.source !== 'upload') { break; } @@ -351,16 +351,16 @@ export class JobService extends BaseService { } const jobs: JobItem[] = [ - { name: JobName.SMART_SEARCH, data: item.data }, - { name: JobName.FACE_DETECTION, data: item.data }, + { name: JobName.SmartSearch, data: item.data }, + { name: JobName.FaceDetection, data: item.data }, ]; - if (asset.type === AssetType.VIDEO) { - jobs.push({ name: JobName.VIDEO_CONVERSION, data: item.data }); + if (asset.type === AssetType.Video) { + jobs.push({ name: JobName.VideoConversation, data: item.data }); } await this.jobRepository.queueAll(jobs); - if (asset.visibility === AssetVisibility.TIMELINE || asset.visibility === AssetVisibility.ARCHIVE) { + if (asset.visibility === AssetVisibility.Timeline || asset.visibility === AssetVisibility.Archive) { this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset)); if (asset.exifInfo) { const exif = asset.exifInfo; @@ -417,14 +417,14 @@ export class JobService extends BaseService { break; } - case JobName.SMART_SEARCH: { + case JobName.SmartSearch: { if (item.data.source === 'upload') { - await this.jobRepository.queue({ name: JobName.DUPLICATE_DETECTION, data: item.data }); + await this.jobRepository.queue({ name: JobName.DuplicateDetection, data: item.data }); } break; } - case JobName.USER_DELETION: { + case JobName.UserDeletion: { this.eventRepository.clientBroadcast('on_user_delete', item.data.id); break; } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index ab69e22b99..aa2b80d928 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -27,7 +27,7 @@ describe(LibraryService.name, () => { ({ sut, mocks } = newTestService(LibraryService, {})); mocks.database.tryLock.mockResolvedValue(true); - mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); }); it('should work', () => { @@ -173,7 +173,7 @@ describe(LibraryService.name, () => { await sut.handleQueueSyncFiles({ id: library.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_SYNC_FILES, + name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: ['/data/user1/photo.jpg'], @@ -185,7 +185,7 @@ describe(LibraryService.name, () => { it('should fail when library is not found', async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); - await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.Skipped); }); it('should ignore import paths that do not exist', async () => { @@ -228,7 +228,7 @@ describe(LibraryService.name, () => { await sut.handleQueueSyncFiles({ id: library.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_SYNC_FILES, + name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: ['/data/user1/photo.jpg'], @@ -240,7 +240,7 @@ describe(LibraryService.name, () => { it("should fail when library can't be found", async () => { const library = factory.library({ importPaths: ['/foo', '/bar'] }); - await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.Skipped); }); it('should ignore import paths that do not exist', async () => { @@ -282,7 +282,7 @@ describe(LibraryService.name, () => { const response = await sut.handleQueueSyncAssets({ id: library.id }); - expect(response).toBe(JobStatus.SUCCESS); + expect(response).toBe(JobStatus.Success); expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith( library.id, library.importPaths, @@ -300,7 +300,7 @@ describe(LibraryService.name, () => { const response = await sut.handleQueueSyncAssets({ id: library.id }); - expect(response).toBe(JobStatus.SUCCESS); + expect(response).toBe(JobStatus.Success); expect(mocks.asset.detectOfflineExternalAssets).not.toHaveBeenCalled(); }); @@ -317,7 +317,7 @@ describe(LibraryService.name, () => { const response = await sut.handleQueueSyncAssets({ id: library.id }); expect(mocks.job.queue).toBeCalledWith({ - name: JobName.LIBRARY_SYNC_ASSETS, + name: JobName.LibrarySyncAssets, data: { libraryId: library.id, importPaths: library.importPaths, @@ -328,7 +328,7 @@ describe(LibraryService.name, () => { }, }); - expect(response).toBe(JobStatus.SUCCESS); + expect(response).toBe(JobStatus.Success); expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith( library.id, library.importPaths, @@ -337,7 +337,7 @@ describe(LibraryService.name, () => { }); it("should fail if library can't be found", async () => { - await expect(sut.handleQueueSyncAssets({ id: newUuid() })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueSyncAssets({ id: newUuid() })).resolves.toBe(JobStatus.Skipped); }); }); @@ -355,7 +355,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, @@ -376,7 +376,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: true, @@ -397,7 +397,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); @@ -415,7 +415,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { isOffline: false, @@ -436,7 +436,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); @@ -456,7 +456,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); @@ -476,7 +476,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); }); @@ -494,7 +494,7 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.updateAll).toHaveBeenCalledWith( [assetStub.trashedOffline.id], @@ -523,11 +523,11 @@ describe(LibraryService.name, () => { mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.storage.stat.mockResolvedValue({ mtime } as Stats); - await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.SIDECAR_DISCOVERY, + name: JobName.SidecarDiscovery, data: { id: assetStub.external.id, source: 'upload', @@ -557,7 +557,7 @@ describe(LibraryService.name, () => { mocks.asset.createAll.mockResolvedValue([assetStub.image]); mocks.library.get.mockResolvedValue(library); - await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Success); expect(mocks.asset.createAll).toHaveBeenCalledWith([ expect.objectContaining({ @@ -565,7 +565,7 @@ describe(LibraryService.name, () => { libraryId: library.id, originalPath: '/data/user1/photo.jpg', deviceId: 'Library Import', - type: AssetType.IMAGE, + type: AssetType.Image, originalFileName: 'photo.jpg', isExternal: true, }), @@ -573,7 +573,7 @@ describe(LibraryService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.SIDECAR_DISCOVERY, + name: JobName.SidecarDiscovery, data: { id: assetStub.image.id, source: 'upload', @@ -592,7 +592,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); - await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSyncFiles(mockLibraryJob)).resolves.toBe(JobStatus.Failed); expect(mocks.asset.createAll.mock.calls).toEqual([]); }); @@ -607,7 +607,7 @@ describe(LibraryService.name, () => { await sut.delete(library.id); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, data: { id: library.id } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.LibraryDelete, data: { id: library.id } }); expect(mocks.library.softDelete).toHaveBeenCalledWith(library.id); }); @@ -620,7 +620,7 @@ describe(LibraryService.name, () => { await sut.delete(library.id); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_DELETE, + name: JobName.LibraryDelete, data: { id: library.id }, }); @@ -838,11 +838,11 @@ describe(LibraryService.name, () => { const library2 = factory.library({ deletedAt: new Date() }); mocks.library.getAllDeleted.mockResolvedValue([library1, library2]); - await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.Success); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_DELETE, data: { id: library1.id } }, - { name: JobName.LIBRARY_DELETE, data: { id: library2.id } }, + { name: JobName.LibraryDelete, data: { id: library1.id } }, + { name: JobName.LibraryDelete, data: { id: library2.id } }, ]); }); }); @@ -968,7 +968,7 @@ describe(LibraryService.name, () => { await sut.watchAll(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_SYNC_FILES, + name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: ['/foo/photo.jpg'], @@ -989,7 +989,7 @@ describe(LibraryService.name, () => { await sut.watchAll(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_SYNC_FILES, + name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: ['/foo/photo.jpg'], @@ -1010,7 +1010,7 @@ describe(LibraryService.name, () => { await sut.watchAll(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_ASSET_REMOVAL, + name: JobName.LibraryAssetRemoval, data: { libraryId: library.id, paths: [assetStub.image.originalPath], @@ -1106,7 +1106,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.library.streamAssetIds.mockReturnValue(makeStream([])); - await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); expect(mocks.library.delete).toHaveBeenCalled(); }); @@ -1117,7 +1117,7 @@ describe(LibraryService.name, () => { mocks.library.get.mockResolvedValue(library); mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.image1])); - await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleDeleteLibrary({ id: library.id })).resolves.toBe(JobStatus.Success); }); }); @@ -1131,11 +1131,11 @@ describe(LibraryService.name, () => { expect(mocks.job.queue).toHaveBeenCalledTimes(2); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_SYNC_FILES, + name: JobName.LibraryQueueSyncFiles, data: { id: library.id }, }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, + name: JobName.LibraryQueueSyncAssets, data: { id: library.id }, }); }); @@ -1147,14 +1147,14 @@ describe(LibraryService.name, () => { mocks.library.getAll.mockResolvedValue([library]); - await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.Success); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LIBRARY_QUEUE_CLEANUP, + name: JobName.LibraryQueueCleanup, data: {}, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.LIBRARY_QUEUE_SYNC_FILES, data: { id: library.id } }, + { name: JobName.LibraryQueueSyncFiles, data: { id: library.id } }, ]); }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 911ea3b702..f8377081fa 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -32,7 +32,7 @@ export class LibraryService extends BaseService { private lock = false; private watchers: Record Promise> = {}; - @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.MICROSERVICES] }) + @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Microservices] }) async onConfigInit({ newConfig: { library: { watch, scan }, @@ -47,8 +47,7 @@ export class LibraryService extends BaseService { this.cronRepository.create({ name: CronJob.LibraryScan, expression: scan.cronExpression, - onTick: () => - handlePromiseError(this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL }), this.logger), + onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.LibraryQueueScanAll }), this.logger), start: scan.enabled, }); } @@ -103,7 +102,7 @@ export class LibraryService extends BaseService { if (matcher(path)) { this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`); await this.jobRepository.queue({ - name: JobName.LIBRARY_SYNC_FILES, + name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths: [path] }, }); } else { @@ -114,7 +113,7 @@ export class LibraryService extends BaseService { const deletionHandler = async (path: string) => { this.logger.debug(`File unlink event received for ${path} in library ${library.id}}`); await this.jobRepository.queue({ - name: JobName.LIBRARY_ASSET_REMOVAL, + name: JobName.LibraryAssetRemoval, data: { libraryId: library.id, paths: [path] }, }); }; @@ -199,7 +198,7 @@ export class LibraryService extends BaseService { return libraries.map((library) => mapLibrary(library)); } - @OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY }) + @OnJob({ name: JobName.LibraryQueueCleanup, queue: QueueName.Library }) async handleQueueCleanup(): Promise { this.logger.log('Checking for any libraries pending deletion...'); const pendingDeletions = await this.libraryRepository.getAllDeleted(); @@ -208,11 +207,11 @@ export class LibraryService extends BaseService { this.logger.log(`Found ${pendingDeletions.length} ${libraryString} pending deletion, cleaning up...`); await this.jobRepository.queueAll( - pendingDeletions.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), + pendingDeletions.map((libraryToDelete) => ({ name: JobName.LibraryDelete, data: { id: libraryToDelete.id } })), ); } - return JobStatus.SUCCESS; + return JobStatus.Success; } async create(dto: CreateLibraryDto): Promise { @@ -225,16 +224,16 @@ export class LibraryService extends BaseService { return mapLibrary(library); } - @OnJob({ name: JobName.LIBRARY_SYNC_FILES, queue: QueueName.LIBRARY }) - async handleSyncFiles(job: JobOf): Promise { + @OnJob({ name: JobName.LibrarySyncFiles, queue: QueueName.Library }) + async handleSyncFiles(job: JobOf): Promise { const library = await this.libraryRepository.get(job.libraryId); // We need to check if the library still exists as it could have been deleted after the scan was queued if (!library) { this.logger.debug(`Library ${job.libraryId} not found, skipping file import`); - return JobStatus.FAILED; + return JobStatus.Failed; } else if (library.deletedAt) { this.logger.debug(`Library ${job.libraryId} is deleted, won't import assets into it`); - return JobStatus.FAILED; + return JobStatus.Failed; } const assetImports: Insertable[] = []; @@ -263,7 +262,7 @@ export class LibraryService extends BaseService { await this.queuePostSyncJobs(assetIds); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async validateImportPath(importPath: string): Promise { @@ -339,11 +338,11 @@ export class LibraryService extends BaseService { } await this.libraryRepository.softDelete(id); - await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); + await this.jobRepository.queue({ name: JobName.LibraryDelete, data: { id } }); } - @OnJob({ name: JobName.LIBRARY_DELETE, queue: QueueName.LIBRARY }) - async handleDeleteLibrary(job: JobOf): Promise { + @OnJob({ name: JobName.LibraryDelete, queue: QueueName.Library }) + async handleDeleteLibrary(job: JobOf): Promise { const libraryId = job.id; await this.assetRepository.updateByLibraryId(libraryId, { deletedAt: new Date() }); @@ -356,7 +355,7 @@ export class LibraryService extends BaseService { assetsFound = true; this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`); await this.jobRepository.queueAll( - chunk.map((id) => ({ name: JobName.ASSET_DELETION, data: { id, deleteOnDisk: false } })), + chunk.map((id) => ({ name: JobName.AssetDeletion, data: { id, deleteOnDisk: false } })), ); chunk = []; } @@ -379,7 +378,7 @@ export class LibraryService extends BaseService { await this.libraryRepository.delete(libraryId); } - return JobStatus.SUCCESS; + return JobStatus.Success; } private async processEntity(filePath: string, ownerId: string, libraryId: string) { @@ -398,7 +397,7 @@ export class LibraryService extends BaseService { // TODO: device asset id is deprecated, remove it deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''), deviceId: 'Library Import', - type: mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE, + type: mimeTypes.isVideo(assetPath) ? AssetType.Video : AssetType.Image, originalFileName: parse(assetPath).base, isExternal: true, livePhotoVideoId: null, @@ -411,7 +410,7 @@ export class LibraryService extends BaseService { // We queue a sidecar discovery which, in turn, queues metadata extraction await this.jobRepository.queueAll( assetIds.map((assetId) => ({ - name: JobName.SIDECAR_DISCOVERY, + name: JobName.SidecarDiscovery, data: { id: assetId, source: 'upload' }, })), ); @@ -423,30 +422,30 @@ export class LibraryService extends BaseService { this.logger.log(`Starting to scan library ${id}`); await this.jobRepository.queue({ - name: JobName.LIBRARY_QUEUE_SYNC_FILES, + name: JobName.LibraryQueueSyncFiles, data: { id, }, }); - await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); + await this.jobRepository.queue({ name: JobName.LibraryQueueSyncAssets, data: { id } }); } async queueScanAll() { - await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, data: {} }); + await this.jobRepository.queue({ name: JobName.LibraryQueueScanAll, data: {} }); } - @OnJob({ name: JobName.LIBRARY_QUEUE_SCAN_ALL, queue: QueueName.LIBRARY }) + @OnJob({ name: JobName.LibraryQueueScanAll, queue: QueueName.Library }) async handleQueueScanAll(): Promise { this.logger.log(`Initiating scan of all external libraries...`); - await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} }); + await this.jobRepository.queue({ name: JobName.LibraryQueueCleanup, data: {} }); const libraries = await this.libraryRepository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LIBRARY_QUEUE_SYNC_FILES, + name: JobName.LibraryQueueSyncFiles, data: { id: library.id, }, @@ -454,18 +453,18 @@ export class LibraryService extends BaseService { ); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, + name: JobName.LibraryQueueSyncAssets, data: { id: library.id, }, })), ); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.LIBRARY_SYNC_ASSETS, queue: QueueName.LIBRARY }) - async handleSyncAssets(job: JobOf): Promise { + @OnJob({ name: JobName.LibrarySyncAssets, queue: QueueName.Library }) + async handleSyncAssets(job: JobOf): Promise { const assets = await this.assetJobRepository.getForSyncAssets(job.assetIds); const assetIdsToOffline: string[] = []; @@ -486,7 +485,7 @@ export class LibraryService extends BaseService { const action = this.checkExistingAsset(asset, stat); switch (action) { case AssetSyncResult.OFFLINE: { - if (asset.status === AssetStatus.TRASHED) { + if (asset.status === AssetStatus.Trashed) { trashedAssetIdsToOffline.push(asset.id); } else { assetIdsToOffline.push(asset.id); @@ -511,7 +510,7 @@ export class LibraryService extends BaseService { if (!isExcluded) { this.logger.debug(`Offline asset ${asset.originalPath} is now online in library ${job.libraryId}`); - if (asset.status === AssetStatus.TRASHED) { + if (asset.status === AssetStatus.Trashed) { trashedAssetIdsToOnline.push(asset.id); } else { assetIdsToOnline.push(asset.id); @@ -557,7 +556,7 @@ export class LibraryService extends BaseService { `Checked existing asset(s): ${assetIdsToOffline.length + trashedAssetIdsToOffline.length} offlined, ${assetIdsToOnline.length + trashedAssetIdsToOnline.length} onlined, ${assetIdsToUpdate.length} updated, ${remainingCount} unchanged of current batch of ${assets.length} (Total progress: ${job.progressCounter} of ${job.totalAssets}, ${cumulativePercentage} %) in library ${job.libraryId}.`, ); - return JobStatus.SUCCESS; + return JobStatus.Success; } private checkExistingAsset( @@ -585,7 +584,7 @@ export class LibraryService extends BaseService { return AssetSyncResult.OFFLINE; } - if (asset.isOffline && asset.status !== AssetStatus.DELETED) { + if (asset.isOffline && asset.status !== AssetStatus.Deleted) { // Only perform the expensive check if the asset is offline return AssetSyncResult.CHECK_OFFLINE; } @@ -599,12 +598,12 @@ export class LibraryService extends BaseService { return AssetSyncResult.DO_NOTHING; } - @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_FILES, queue: QueueName.LIBRARY }) - async handleQueueSyncFiles(job: JobOf): Promise { + @OnJob({ name: JobName.LibraryQueueSyncFiles, queue: QueueName.Library }) + async handleQueueSyncFiles(job: JobOf): Promise { const library = await this.libraryRepository.get(job.id); if (!library) { this.logger.debug(`Library ${job.id} not found, skipping refresh`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } this.logger.debug(`Validating import paths for library ${library.id}...`); @@ -623,7 +622,7 @@ export class LibraryService extends BaseService { if (validImportPaths.length === 0) { this.logger.warn(`No valid import paths found for library ${library.id}`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const pathsOnDisk = this.storageRepository.walk({ @@ -646,7 +645,7 @@ export class LibraryService extends BaseService { importCount += paths.length; await this.jobRepository.queue({ - name: JobName.LIBRARY_SYNC_FILES, + name: JobName.LibrarySyncFiles, data: { libraryId: library.id, paths, @@ -666,11 +665,11 @@ export class LibraryService extends BaseService { await this.libraryRepository.update(job.id, { refreshedAt: new Date() }); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.LIBRARY_ASSET_REMOVAL, queue: QueueName.LIBRARY }) - async handleAssetRemoval(job: JobOf): Promise { + @OnJob({ name: JobName.LibraryAssetRemoval, queue: QueueName.Library }) + async handleAssetRemoval(job: JobOf): Promise { // This is only for handling file unlink events via the file watcher this.logger.verbose(`Deleting asset(s) ${job.paths} from library ${job.libraryId}`); for (const assetPath of job.paths) { @@ -680,20 +679,20 @@ export class LibraryService extends BaseService { } } - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, queue: QueueName.LIBRARY }) - async handleQueueSyncAssets(job: JobOf): Promise { + @OnJob({ name: JobName.LibraryQueueSyncAssets, queue: QueueName.Library }) + async handleQueueSyncAssets(job: JobOf): Promise { const library = await this.libraryRepository.get(job.id); if (!library) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const assetCount = await this.assetRepository.getLibraryAssetCount(job.id); if (!assetCount) { this.logger.log(`Library ${library.id} is empty, no need to check assets`); - return JobStatus.SUCCESS; + return JobStatus.Success; } this.logger.log( @@ -713,7 +712,7 @@ export class LibraryService extends BaseService { ); if (affectedAssetCount === assetCount) { - return JobStatus.SUCCESS; + return JobStatus.Success; } let chunk: string[] = []; @@ -724,7 +723,7 @@ export class LibraryService extends BaseService { count += chunk.length; await this.jobRepository.queue({ - name: JobName.LIBRARY_SYNC_ASSETS, + name: JobName.LibrarySyncAssets, data: { libraryId: library.id, importPaths: library.importPaths, @@ -758,7 +757,7 @@ export class LibraryService extends BaseService { this.logger.log(`Finished queuing ${count} asset check(s) for library ${library.id}`); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async findOrFail(id: string) { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 3b9eafde8f..f535bb65ff 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -12,7 +12,7 @@ import { JobName, JobStatus, RawExtractedFormat, - TranscodeHWAccel, + TranscodeHardwareAcceleration, TranscodePolicy, VideoCodec, } from 'src/enum'; @@ -49,7 +49,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -57,7 +57,7 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: personStub.newThumbnail.id }, }, ]); @@ -72,7 +72,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: assetStub.trashed.id }, }, ]); @@ -87,7 +87,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: assetStub.archived.id }, }, ]); @@ -106,7 +106,7 @@ describe(MediaService.name, () => { expect(mocks.person.update).toHaveBeenCalledTimes(1); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: personStub.newThumbnail.id, }, @@ -122,7 +122,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -138,7 +138,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -154,7 +154,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -169,14 +169,14 @@ describe(MediaService.name, () => { mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); - await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success); expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.MIGRATE_ASSET, data: { id: assetStub.image.id } }, + { name: JobName.MigrateAsset, data: { id: assetStub.image.id } }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.MIGRATE_PERSON, data: { id: personStub.withName.id } }, + { name: JobName.MigratePerson, data: { id: personStub.withName.id } }, ]); }); }); @@ -184,7 +184,7 @@ describe(MediaService.name, () => { describe('handleAssetMigration', () => { it('should fail if asset does not exist', async () => { mocks.assetJob.getForMigrationJob.mockResolvedValue(void 0); - await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed); expect(mocks.move.getByEntity).not.toHaveBeenCalled(); }); @@ -196,25 +196,25 @@ describe(MediaService.name, () => { id: 'move-id', newPath: '/new/path', oldPath: '/old/path', - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, }); - await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, - pathType: AssetPathType.FULLSIZE, + pathType: AssetPathType.FullSize, oldPath: '/uploads/user-id/fullsize/path.webp', newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', }); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, - pathType: AssetPathType.PREVIEW, + pathType: AssetPathType.Preview, oldPath: '/uploads/user-id/thumbs/path.jpg', newPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, - pathType: AssetPathType.THUMBNAIL, + pathType: AssetPathType.Thumbnail, oldPath: '/uploads/user-id/webp/path.ext', newPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }); @@ -253,7 +253,7 @@ describe(MediaService.name, () => { it('should skip thumbnail generation if asset type is unknown', async () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.image, type: 'foo' as AssetType }); - await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleGenerateThumbnails({ id: assetStub.image.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.media.probe).not.toHaveBeenCalled(); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); @@ -270,14 +270,14 @@ describe(MediaService.name, () => { it('should skip invisible assets', async () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.livePhotoMotionAsset); - expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleGenerateThumbnails({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); it('should delete previous preview if different path', async () => { - mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -309,7 +309,7 @@ describe(MediaService.name, () => { rawBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, size: 1440, quality: 80, processInvalidImages: false, @@ -321,7 +321,7 @@ describe(MediaService.name, () => { rawBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.WEBP, + format: ImageFormat.Webp, size: 250, quality: 80, processInvalidImages: false, @@ -340,12 +340,12 @@ describe(MediaService.name, () => { expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', - type: AssetFileType.PREVIEW, + type: AssetFileType.Preview, path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }, { assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, + type: AssetFileType.Thumbnail, path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }, ]); @@ -376,12 +376,12 @@ describe(MediaService.name, () => { expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', - type: AssetFileType.PREVIEW, + type: AssetFileType.Preview, path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }, { assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, + type: AssetFileType.Thumbnail, path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }, ]); @@ -411,12 +411,12 @@ describe(MediaService.name, () => { expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([ { assetId: 'asset-id', - type: AssetFileType.PREVIEW, + type: AssetFileType.Preview, path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', }, { assetId: 'asset-id', - type: AssetFileType.THUMBNAIL, + type: AssetFileType.Thumbnail, path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', }, ]); @@ -465,7 +465,7 @@ describe(MediaService.name, () => { it('should use scaling divisible by 2 even when using quick sync', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); @@ -493,7 +493,7 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { - colorspace: Colorspace.SRGB, + colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, }); @@ -502,7 +502,7 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { - colorspace: Colorspace.SRGB, + colorspace: Colorspace.Srgb, format, size: 1440, quality: 80, @@ -514,8 +514,8 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { - colorspace: Colorspace.SRGB, - format: ImageFormat.WEBP, + colorspace: Colorspace.Srgb, + format: ImageFormat.Webp, size: 250, quality: 80, processInvalidImages: false, @@ -538,7 +538,7 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { - colorspace: Colorspace.SRGB, + colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, }); @@ -547,8 +547,8 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { - colorspace: Colorspace.SRGB, - format: ImageFormat.JPEG, + colorspace: Colorspace.Srgb, + format: ImageFormat.Jpeg, size: 1440, quality: 80, processInvalidImages: false, @@ -559,7 +559,7 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, { - colorspace: Colorspace.SRGB, + colorspace: Colorspace.Srgb, format, size: 250, quality: 80, @@ -571,7 +571,7 @@ describe(MediaService.name, () => { }); it('should delete previous thumbnail if different path', async () => { - mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); + mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); @@ -580,7 +580,7 @@ describe(MediaService.name, () => { }); it('should extract embedded image if enabled and available', async () => { - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -596,7 +596,7 @@ describe(MediaService.name, () => { }); it('should resize original image if embedded image is too small', async () => { - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 }); mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -678,9 +678,9 @@ describe(MediaService.name, () => { it('should extract full-size JPEG preview from RAW', async () => { mocks.systemMetadata.get.mockResolvedValue({ - image: { fullsize: { enabled: true, format: ImageFormat.WEBP }, extractEmbedded: true }, + image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -698,7 +698,7 @@ describe(MediaService.name, () => { fullsizeBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, size: 1440, quality: 80, processInvalidImages: false, @@ -710,9 +710,9 @@ describe(MediaService.name, () => { it('should convert full-size WEBP preview from JXL preview of RAW', async () => { mocks.systemMetadata.get.mockResolvedValue({ - image: { fullsize: { enabled: true, format: ImageFormat.WEBP }, extractEmbedded: true }, + image: { fullsize: { enabled: true, format: ImageFormat.Webp }, extractEmbedded: true }, }); - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JXL }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -729,7 +729,7 @@ describe(MediaService.name, () => { fullsizeBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.WEBP, + format: ImageFormat.Webp, quality: 80, processInvalidImages: false, raw: rawInfo, @@ -740,7 +740,7 @@ describe(MediaService.name, () => { fullsizeBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, size: 1440, quality: 80, processInvalidImages: false, @@ -752,7 +752,7 @@ describe(MediaService.name, () => { it('should generate full-size preview directly from RAW images when extractEmbedded is false', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } }); - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageDng); @@ -769,7 +769,7 @@ describe(MediaService.name, () => { rawBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, processInvalidImages: false, raw: rawInfo, @@ -780,7 +780,7 @@ describe(MediaService.name, () => { rawBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, size: 1440, processInvalidImages: false, @@ -792,7 +792,7 @@ describe(MediaService.name, () => { it('should generate full-size preview from non-web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); @@ -810,7 +810,7 @@ describe(MediaService.name, () => { rawBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, processInvalidImages: false, raw: rawInfo, @@ -821,7 +821,7 @@ describe(MediaService.name, () => { it('should skip generating full-size preview for web-friendly images', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); @@ -829,7 +829,7 @@ describe(MediaService.name, () => { expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { - colorspace: Colorspace.SRGB, + colorspace: Colorspace.Srgb, processInvalidImages: false, size: 1440, }); @@ -844,9 +844,9 @@ describe(MediaService.name, () => { it('should respect encoding options when generating full-size preview', async () => { mocks.systemMetadata.get.mockResolvedValue({ - image: { fullsize: { enabled: true, format: ImageFormat.WEBP, quality: 90 } }, + image: { fullsize: { enabled: true, format: ImageFormat.Webp, quality: 90 } }, }); - mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); @@ -864,7 +864,7 @@ describe(MediaService.name, () => { rawBuffer, { colorspace: Colorspace.P3, - format: ImageFormat.WEBP, + format: ImageFormat.Webp, quality: 90, processInvalidImages: false, raw: rawInfo, @@ -878,7 +878,7 @@ describe(MediaService.name, () => { it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.Skipped); expect(mocks.asset.getByIds).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); @@ -907,7 +907,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); @@ -921,7 +921,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { left: 238, @@ -949,7 +949,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); @@ -963,7 +963,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { left: 238, @@ -991,7 +991,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, { @@ -1003,7 +1003,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { left: 0, @@ -1028,7 +1028,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, { @@ -1040,7 +1040,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { left: 591, @@ -1065,7 +1065,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, { @@ -1077,7 +1077,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { left: 0, @@ -1102,7 +1102,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, { @@ -1114,7 +1114,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { left: 4485, @@ -1138,12 +1138,12 @@ describe(MediaService.name, () => { const extracted = Buffer.from(''); const data = Buffer.from(''); const info = { width: 2160, height: 3840 } as OutputInfo; - mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.decodeImage.mockResolvedValue({ data, info }); mocks.media.getImageDimensions.mockResolvedValue(info); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); @@ -1156,7 +1156,7 @@ describe(MediaService.name, () => { data, { colorspace: Colorspace.P3, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, crop: { height: 844, @@ -1180,7 +1180,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.extract).not.toHaveBeenCalled(); @@ -1196,7 +1196,7 @@ describe(MediaService.name, () => { mocks.media.decodeImage.mockResolvedValue({ data, info }); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); @@ -1216,11 +1216,11 @@ describe(MediaService.name, () => { const data = Buffer.from(''); const info = { width: 1000, height: 1000 } as OutputInfo; mocks.media.decodeImage.mockResolvedValue({ data, info }); - mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.JPEG }); + mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue(info); await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe( - JobStatus.SUCCESS, + JobStatus.Success, ); expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath); @@ -1243,7 +1243,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.VIDEO_CONVERSION, + name: JobName.VideoConversation, data: { id: assetStub.video.id }, }, ]); @@ -1257,7 +1257,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(void 0); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.VIDEO_CONVERSION, + name: JobName.VideoConversation, data: { id: assetStub.video.id }, }, ]); @@ -1340,17 +1340,17 @@ describe(MediaService.name, () => { it('should throw an error if transcoding fails and hw acceleration is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { transcode: TranscodePolicy.ALL, accel: TranscodeHWAccel.DISABLED }, + ffmpeg: { transcode: TranscodePolicy.All, accel: TranscodeHardwareAcceleration.Disabled }, }); mocks.media.transcode.mockRejectedValue(new Error('Error transcoding video')); - await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.Failed); expect(mocks.media.transcode).toHaveBeenCalledTimes(1); }); it('should transcode when set to all', async () => { mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.ALL } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.All } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1365,7 +1365,7 @@ describe(MediaService.name, () => { it('should transcode when optimal and too big', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1380,7 +1380,7 @@ describe(MediaService.name, () => { it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: '30M' } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1395,7 +1395,7 @@ describe(MediaService.name, () => { it('should transcode when max bitrate is not a number', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.BITRATE, maxBitrate: 'foo' } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1411,7 +1411,7 @@ describe(MediaService.name, () => { it('should not scale resolution if no target resolution', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1427,7 +1427,7 @@ describe(MediaService.name, () => { it('should scale horizontally when video is horizontal', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1442,7 +1442,7 @@ describe(MediaService.name, () => { it('should scale vertically when video is vertical', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1458,7 +1458,7 @@ describe(MediaService.name, () => { it('should always scale video if height is uneven', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddHeight); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1475,7 +1475,7 @@ describe(MediaService.name, () => { it('should always scale video if width is uneven', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamOddWidth); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { transcode: TranscodePolicy.ALL, targetResolution: 'original' }, + ffmpeg: { transcode: TranscodePolicy.All, targetResolution: 'original' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1492,7 +1492,7 @@ describe(MediaService.name, () => { it('should copy video stream when video matches target', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { targetVideoCodec: VideoCodec.HEVC, acceptedAudioCodecs: [AudioCodec.AAC] }, + ffmpeg: { targetVideoCodec: VideoCodec.Hevc, acceptedAudioCodecs: [AudioCodec.Aac] }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1510,9 +1510,9 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamH264); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { - targetVideoCodec: VideoCodec.HEVC, - acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], - acceptedAudioCodecs: [AudioCodec.AAC], + targetVideoCodec: VideoCodec.Hevc, + acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.Hevc], + acceptedAudioCodecs: [AudioCodec.Aac], }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1531,9 +1531,9 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { - targetVideoCodec: VideoCodec.HEVC, - acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.HEVC], - acceptedAudioCodecs: [AudioCodec.AAC], + targetVideoCodec: VideoCodec.Hevc, + acceptedVideoCodecs: [VideoCodec.H264, VideoCodec.Hevc], + acceptedAudioCodecs: [AudioCodec.Aac], }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1550,7 +1550,7 @@ describe(MediaService.name, () => { it('should copy audio stream when audio matches target', async () => { mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1587,14 +1587,14 @@ describe(MediaService.name, () => { it('should not transcode if transcoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should not remux when input is not an accepted container and transcoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); @@ -1609,14 +1609,14 @@ describe(MediaService.name, () => { it('should delete existing transcode if current policy does not require transcoding', async () => { const asset = assetStub.hasEncodedVideo; mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } }); mocks.assetJob.getForVideoConversion.mockResolvedValue(asset); await sut.handleVideoConversion({ id: asset.id }); expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DELETE_FILES, + name: JobName.DeleteFiles, data: { files: [asset.encodedVideoPath] }, }); }); @@ -1687,7 +1687,7 @@ describe(MediaService.name, () => { ffmpeg: { maxBitrate: '4500k', twoPass: true, - targetVideoCodec: VideoCodec.VP9, + targetVideoCodec: VideoCodec.Vp9, }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1708,7 +1708,7 @@ describe(MediaService.name, () => { ffmpeg: { maxBitrate: '0', twoPass: true, - targetVideoCodec: VideoCodec.VP9, + targetVideoCodec: VideoCodec.Vp9, }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -1725,7 +1725,7 @@ describe(MediaService.name, () => { it('should configure preset for vp9', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, preset: 'slow' } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Vp9, preset: 'slow' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1740,7 +1740,7 @@ describe(MediaService.name, () => { it('should not configure preset for vp9 if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.VP9 } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { preset: 'invalid', targetVideoCodec: VideoCodec.Vp9 } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1755,7 +1755,7 @@ describe(MediaService.name, () => { it('should configure threads if above 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.VP9, threads: 2 } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Vp9, threads: 2 } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1800,7 +1800,7 @@ describe(MediaService.name, () => { it('should disable thread pooling for hevc if thread limit is 1', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.HEVC } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 1, targetVideoCodec: VideoCodec.Hevc } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1815,7 +1815,7 @@ describe(MediaService.name, () => { it('should omit thread flags for hevc if thread limit is at or below 0', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.HEVC } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { threads: 0, targetVideoCodec: VideoCodec.Hevc } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1830,7 +1830,7 @@ describe(MediaService.name, () => { it('should use av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1 } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1 } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1855,7 +1855,7 @@ describe(MediaService.name, () => { it('should map `veryslow` preset to 4 for av1', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, preset: 'veryslow' } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, preset: 'veryslow' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1870,7 +1870,7 @@ describe(MediaService.name, () => { it('should set max bitrate for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, maxBitrate: '2M' } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, maxBitrate: '2M' } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1885,7 +1885,7 @@ describe(MediaService.name, () => { it('should set threads for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4 } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: VideoCodec.Av1, threads: 4 } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1901,7 +1901,7 @@ describe(MediaService.name, () => { it('should set both bitrate and threads for av1 if specified', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { targetVideoCodec: VideoCodec.AV1, threads: 4, maxBitrate: '2M' }, + ffmpeg: { targetVideoCodec: VideoCodec.Av1, threads: 4, maxBitrate: '2M' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -1919,8 +1919,8 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { - targetVideoCodec: VideoCodec.HEVC, - transcode: TranscodePolicy.OPTIMAL, + targetVideoCodec: VideoCodec.Hevc, + transcode: TranscodePolicy.Optimal, targetResolution: '1080p', }, }); @@ -1931,7 +1931,7 @@ describe(MediaService.name, () => { it('should fail if hwaccel is enabled for an unsupported codec', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.NVENC, targetVideoCodec: VideoCodec.VP9 }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 }, }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); @@ -1946,7 +1946,7 @@ describe(MediaService.name, () => { it('should set options for nvenc', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -1979,7 +1979,7 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { - accel: TranscodeHWAccel.NVENC, + accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k', twoPass: true, }, @@ -1998,7 +1998,9 @@ describe(MediaService.name, () => { it('should set vbr options for nvenc when max bitrate is enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2013,7 +2015,9 @@ describe(MediaService.name, () => { it('should set cq options for nvenc when max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, maxBitrate: '10000k' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, maxBitrate: '10000k' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2028,7 +2032,9 @@ describe(MediaService.name, () => { it('should omit preset for nvenc if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC, preset: 'invalid' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, preset: 'invalid' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2043,7 +2049,7 @@ describe(MediaService.name, () => { it('should ignore two pass for nvenc if max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.NVENC } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2059,7 +2065,7 @@ describe(MediaService.name, () => { it('should use hardware decoding for nvenc if enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2081,7 +2087,7 @@ describe(MediaService.name, () => { it('should use hardware tone-mapping for nvenc if hardware decoding is enabled and should tone map', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2102,7 +2108,7 @@ describe(MediaService.name, () => { it('should set format to nv12 for nvenc if input is not yuv420p', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2118,7 +2124,9 @@ describe(MediaService.name, () => { it('should set options for qsv', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, maxBitrate: '10000k' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, maxBitrate: '10000k' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2154,7 +2162,7 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { - accel: TranscodeHWAccel.QSV, + accel: TranscodeHardwareAcceleration.Qsv, maxBitrate: '10000k', preferredHwDevice: '/dev/dri/renderD128', }, @@ -2176,7 +2184,9 @@ describe(MediaService.name, () => { it('should omit preset for qsv if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV, preset: 'invalid' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, preset: 'invalid' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2195,7 +2205,7 @@ describe(MediaService.name, () => { it('should set low power mode for qsv if target video codec is vp9', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.QSV, targetVideoCodec: VideoCodec.VP9 }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, targetVideoCodec: VideoCodec.Vp9 }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2215,7 +2225,7 @@ describe(MediaService.name, () => { it('should fail for qsv if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); @@ -2225,7 +2235,7 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for qsv', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.QSV } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2244,7 +2254,7 @@ describe(MediaService.name, () => { it('should use hardware decoding for qsv if enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2270,7 +2280,7 @@ describe(MediaService.name, () => { it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2299,7 +2309,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true, preferredHwDevice: 'renderD129' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true, preferredHwDevice: 'renderD129' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2317,7 +2327,7 @@ describe(MediaService.name, () => { it('should set format to nv12 for qsv if input is not yuv420p', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2340,7 +2350,7 @@ describe(MediaService.name, () => { it('should set options for vaapi', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2370,7 +2380,9 @@ describe(MediaService.name, () => { it('should set vbr options for vaapi when max bitrate is enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, maxBitrate: '10000k' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, maxBitrate: '10000k' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2394,7 +2406,7 @@ describe(MediaService.name, () => { it('should set cq options for vaapi when max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2418,7 +2430,9 @@ describe(MediaService.name, () => { it('should omit preset for vaapi if invalid', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, preset: 'invalid' } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, preset: 'invalid' }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2437,7 +2451,7 @@ describe(MediaService.name, () => { it('should prefer higher index renderD* device for vaapi', async () => { sut.videoInterfaces = { dri: ['card1', 'renderD129', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2457,7 +2471,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['renderD129', 'card1', 'card0', 'renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.VAAPI, preferredHwDevice: '/dev/dri/renderD128' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, preferredHwDevice: '/dev/dri/renderD128' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2477,7 +2491,7 @@ describe(MediaService.name, () => { it('should use hardware decoding for vaapi if enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2502,7 +2516,7 @@ describe(MediaService.name, () => { it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2525,7 +2539,7 @@ describe(MediaService.name, () => { it('should set format to nv12 for vaapi if input is not yuv420p', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2545,7 +2559,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['renderD128', 'renderD129', 'renderD130'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true, preferredHwDevice: 'renderD129' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true, preferredHwDevice: 'renderD129' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2562,7 +2576,9 @@ describe(MediaService.name, () => { it('should fallback to hw encoding and sw decoding if hw transcoding fails and hw decoding is enabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, + }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledTimes(2); @@ -2582,7 +2598,9 @@ describe(MediaService.name, () => { it('should fallback to sw decoding if fallback to sw decoding + hw encoding fails', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: true }, + }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2600,7 +2618,7 @@ describe(MediaService.name, () => { it('should fallback to sw transcoding if hw transcoding fails and hw decoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); mocks.media.transcode.mockRejectedValueOnce(new Error('error')); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledTimes(2); @@ -2618,14 +2636,16 @@ describe(MediaService.name, () => { it('should fail for vaapi if no hw devices', async () => { sut.videoInterfaces = { dri: [], mali: true }; mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.VAAPI } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } }); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).rejects.toThrowError(); expect(mocks.media.transcode).not.toHaveBeenCalled(); }); it('should set options for rkmpp', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true }, + }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2660,10 +2680,10 @@ describe(MediaService.name, () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamVp9); mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { - accel: TranscodeHWAccel.RKMPP, + accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, maxBitrate: '10000k', - targetVideoCodec: VideoCodec.HEVC, + targetVideoCodec: VideoCodec.Hevc, }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); @@ -2681,7 +2701,7 @@ describe(MediaService.name, () => { it('should set cqp options for rkmpp when max bitrate is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2698,7 +2718,7 @@ describe(MediaService.name, () => { it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2720,7 +2740,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2739,7 +2759,7 @@ describe(MediaService.name, () => { it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: false, crf: 30, maxBitrate: '0' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: false, crf: 30, maxBitrate: '0' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2761,7 +2781,7 @@ describe(MediaService.name, () => { sut.videoInterfaces = { dri: ['renderD128'], mali: false }; mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); mocks.systemMetadata.get.mockResolvedValue({ - ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' }, + ffmpeg: { accel: TranscodeHardwareAcceleration.Rkmpp, accelDecode: true, crf: 30, maxBitrate: '0' }, }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( @@ -2781,7 +2801,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is required and video is hdr', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2800,7 +2820,7 @@ describe(MediaService.name, () => { it('should tonemap when policy is optimal and video is hdr', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Optimal } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2819,7 +2839,7 @@ describe(MediaService.name, () => { it('should transcode when policy is required and video is not yuv420p', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream10Bit); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', @@ -2834,7 +2854,7 @@ describe(MediaService.name, () => { it('should convert to yuv420p when scaling without tone-mapping', async () => { mocks.media.probe.mockResolvedValue(probeStub.videoStream4K10Bit); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 452e4df5eb..b48df3797a 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -18,7 +18,7 @@ import { QueueName, RawExtractedFormat, StorageFolder, - TranscodeHWAccel, + TranscodeHardwareAcceleration, TranscodePolicy, TranscodeTarget, VideoCodec, @@ -57,8 +57,8 @@ export class MediaService extends BaseService { this.videoInterfaces = { dri, mali }; } - @OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION }) - async handleQueueGenerateThumbnails({ force }: JobOf): Promise { + @OnJob({ name: JobName.QueueGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) + async handleQueueGenerateThumbnails({ force }: JobOf): Promise { let jobs: JobItem[] = []; const queueAll = async () => { @@ -70,7 +70,7 @@ export class MediaService extends BaseService { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { - jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); + jobs.push({ name: JobName.GenerateThumbnails, data: { id: asset.id } }); } if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { @@ -92,7 +92,7 @@ export class MediaService extends BaseService { await this.personRepository.update({ id: person.id, faceAssetId: face.id }); } - jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); + jobs.push({ name: JobName.GeneratePersonThumbnail, data: { id: person.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -100,21 +100,21 @@ export class MediaService extends BaseService { await queueAll(); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION }) + @OnJob({ name: JobName.QueueMigration, queue: QueueName.Migration }) async handleQueueMigration(): Promise { - const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION); + const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.Migration); if (active === 1 && waiting === 0) { - await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS); - await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO); + await this.storageCore.removeEmptyDirs(StorageFolder.Thumbnails); + await this.storageCore.removeEmptyDirs(StorageFolder.EncodedVideo); } let jobs: JobItem[] = []; const assets = this.assetJobRepository.streamForMigrationJob(); for await (const asset of assets) { - jobs.push({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } }); + jobs.push({ name: JobName.MigrateAsset, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); jobs = []; @@ -125,7 +125,7 @@ export class MediaService extends BaseService { jobs = []; for await (const person of this.personRepository.getAll()) { - jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } }); + jobs.push({ name: JobName.MigratePerson, data: { id: person.id } }); if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); @@ -135,36 +135,36 @@ export class MediaService extends BaseService { await this.jobRepository.queueAll(jobs); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION }) - async handleAssetMigration({ id }: JobOf): Promise { + @OnJob({ name: JobName.MigrateAsset, queue: QueueName.Migration }) + async handleAssetMigration({ id }: JobOf): Promise { const { image } = await this.getConfig({ withCache: true }); const asset = await this.assetJobRepository.getForMigrationJob(id); if (!asset) { - return JobStatus.FAILED; + return JobStatus.Failed; } - await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, image.fullsize.format); - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION }) - async handleGenerateThumbnails({ id }: JobOf): Promise { + @OnJob({ name: JobName.GenerateThumbnails, queue: QueueName.ThumbnailGeneration }) + async handleGenerateThumbnails({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); - return JobStatus.FAILED; + return JobStatus.Failed; } - if (asset.visibility === AssetVisibility.HIDDEN) { + if (asset.visibility === AssetVisibility.Hidden) { this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } let generated: { @@ -173,27 +173,27 @@ export class MediaService extends BaseService { fullsizePath?: string; thumbhash: Buffer; }; - if (asset.type === AssetType.VIDEO || asset.originalFileName.toLowerCase().endsWith('.gif')) { + if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { generated = await this.generateVideoThumbnails(asset); - } else if (asset.type === AssetType.IMAGE) { + } else if (asset.type === AssetType.Image) { generated = await this.generateImageThumbnails(asset); } else { this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files); const toUpsert: UpsertFileOptions[] = []; if (previewFile?.path !== generated.previewPath) { - toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW }); + toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.Preview }); } if (thumbnailFile?.path !== generated.thumbnailPath) { - toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL }); + toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.Thumbnail }); } if (generated.fullsizePath && fullsizeFile?.path !== generated.fullsizePath) { - toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FULLSIZE }); + toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FullSize }); } if (toUpsert.length > 0) { @@ -230,7 +230,7 @@ export class MediaService extends BaseService { await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() }); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async extractImage(originalPath: string, minSize: number) { @@ -244,7 +244,7 @@ export class MediaService extends BaseService { private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) { const { image } = await this.getConfig({ withCache: true }); - const colorspace = this.isSRGB(exifInfo) ? Colorspace.SRGB : image.colorspace; + const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace; const decodeOptions: DecodeToBufferOptions = { colorspace, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', @@ -264,8 +264,8 @@ export class MediaService extends BaseService { exifInfo: Exif; }) { const { image } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); - const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); // Handle embedded preview extraction for RAW files @@ -294,11 +294,11 @@ export class MediaService extends BaseService { if (convertFullsize) { // convert a new fullsize image from the same source as the thumbnail - fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format); + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, image.fullsize.format); const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions }; promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath)); - } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.JPEG) { - fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, extracted.format); + } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { + fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format); this.storageCore.ensureFolders(fullsizePath); // Write the buffer to disk with essential EXIF data @@ -317,25 +317,25 @@ export class MediaService extends BaseService { return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; } - @OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION }) - async handleGeneratePersonThumbnail({ id }: JobOf): Promise { + @OnJob({ name: JobName.GeneratePersonThumbnail, queue: QueueName.ThumbnailGeneration }) + async handleGeneratePersonThumbnail({ id }: JobOf): Promise { const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const data = await this.personRepository.getDataForThumbnailGenerationJob(id); if (!data) { this.logger.error(`Could not generate person thumbnail for ${id}: missing data`); - return JobStatus.FAILED; + return JobStatus.Failed; } const { ownerId, x1, y1, x2, y2, oldWidth, oldHeight, exifOrientation, previewPath, originalPath } = data; let inputImage: string | Buffer; - if (data.type === AssetType.VIDEO) { + if (data.type === AssetType.Video) { if (!previewPath) { this.logger.error(`Could not generate person thumbnail for video ${id}: missing preview path`); - return JobStatus.FAILED; + return JobStatus.Failed; } inputImage = previewPath; } else if (image.extractEmbedded && mimeTypes.isRaw(originalPath)) { @@ -357,7 +357,7 @@ export class MediaService extends BaseService { const thumbnailOptions = { colorspace: image.colorspace, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, raw: info, quality: image.thumbnail.quality, crop: this.getCrop( @@ -371,7 +371,7 @@ export class MediaService extends BaseService { await this.mediaRepository.generateThumbnail(decodedImage, thumbnailOptions, thumbnailPath); await this.personRepository.update({ id, thumbnailPath }); - return JobStatus.SUCCESS; + return JobStatus.Success; } private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions { @@ -411,8 +411,8 @@ export class MediaService extends BaseService { private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) { const { image, ffmpeg } = await this.getConfig({ withCache: true }); - const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format); - const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); + const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format); + const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format); this.storageCore.ensureFolders(previewPath); const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath); @@ -424,9 +424,9 @@ export class MediaService extends BaseService { const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() }); const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() }); - const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream, format); + const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format); const thumbnailOptions = thumbnailConfig.getCommand( - TranscodeTarget.VIDEO, + TranscodeTarget.Video, mainVideoStream, mainAudioStream, format, @@ -443,13 +443,13 @@ export class MediaService extends BaseService { return { previewPath, thumbnailPath, thumbhash }; } - @OnJob({ name: JobName.QUEUE_VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION }) - async handleQueueVideoConversion(job: JobOf): Promise { + @OnJob({ name: JobName.QueueVideoConversion, queue: QueueName.VideoConversion }) + async handleQueueVideoConversion(job: JobOf): Promise { const { force } = job; - let queue: { name: JobName.VIDEO_CONVERSION; data: { id: string } }[] = []; + let queue: { name: JobName.VideoConversation; data: { id: string } }[] = []; for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) { - queue.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } }); + queue.push({ name: JobName.VideoConversation, data: { id: asset.id } }); if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(queue); @@ -459,14 +459,14 @@ export class MediaService extends BaseService { await this.jobRepository.queueAll(queue); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION }) - async handleVideoConversion({ id }: JobOf): Promise { + @OnJob({ name: JobName.VideoConversation, queue: QueueName.VideoConversion }) + async handleVideoConversion({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForVideoConversion(id); if (!asset) { - return JobStatus.FAILED; + return JobStatus.Failed; } const input = asset.originalPath; @@ -474,35 +474,35 @@ export class MediaService extends BaseService { this.storageCore.ensureFolders(output); const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { - countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs }); const videoStream = this.getMainStream(videoStreams); const audioStream = this.getMainStream(audioStreams); if (!videoStream || !format.formatName) { - return JobStatus.FAILED; + return JobStatus.Failed; } if (!videoStream.height || !videoStream.width) { this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`); - return JobStatus.FAILED; + return JobStatus.Failed; } let { ffmpeg } = await this.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream); - if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { + if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); + await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream); - if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) { this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`); } else { this.logger.log( @@ -514,8 +514,8 @@ export class MediaService extends BaseService { await this.mediaRepository.transcode(input, output, command); } catch (error: any) { this.logger.error(`Error occurred during transcoding: ${error.message}`); - if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { - return JobStatus.FAILED; + if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) { + return JobStatus.Failed; } let partialFallbackSuccess = false; @@ -533,7 +533,7 @@ export class MediaService extends BaseService { if (!partialFallbackSuccess) { this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`); - ffmpeg = { ...ffmpeg, accel: TranscodeHWAccel.DISABLED }; + ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled }; const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream); await this.mediaRepository.transcode(input, output, command); } @@ -543,7 +543,7 @@ export class MediaService extends BaseService { await this.assetRepository.update({ id: asset.id, encodedVideoPath: output }); - return JobStatus.SUCCESS; + return JobStatus.Success; } private getMainStream(streams: T[]): T { @@ -561,18 +561,18 @@ export class MediaService extends BaseService { const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream); if (isAudioTranscodeRequired && isVideoTranscodeRequired) { - return TranscodeTarget.ALL; + return TranscodeTarget.All; } if (isAudioTranscodeRequired) { - return TranscodeTarget.AUDIO; + return TranscodeTarget.Audio; } if (isVideoTranscodeRequired) { - return TranscodeTarget.VIDEO; + return TranscodeTarget.Video; } - return TranscodeTarget.NONE; + return TranscodeTarget.None; } private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean { @@ -581,15 +581,15 @@ export class MediaService extends BaseService { } switch (ffmpegConfig.transcode) { - case TranscodePolicy.DISABLED: { + case TranscodePolicy.Disabled: { return false; } - case TranscodePolicy.ALL: { + case TranscodePolicy.All: { return true; } - case TranscodePolicy.REQUIRED: - case TranscodePolicy.OPTIMAL: - case TranscodePolicy.BITRATE: { + case TranscodePolicy.Required: + case TranscodePolicy.Optimal: + case TranscodePolicy.Bitrate: { return !ffmpegConfig.acceptedAudioCodecs.includes(stream.codecName as AudioCodec); } default: { @@ -608,19 +608,19 @@ export class MediaService extends BaseService { const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p'); switch (ffmpegConfig.transcode) { - case TranscodePolicy.DISABLED: { + case TranscodePolicy.Disabled: { return false; } - case TranscodePolicy.ALL: { + case TranscodePolicy.All: { return true; } - case TranscodePolicy.REQUIRED: { + case TranscodePolicy.Required: { return isRequired; } - case TranscodePolicy.OPTIMAL: { + case TranscodePolicy.Optimal: { return isRequired || isLargerThanTargetRes; } - case TranscodePolicy.BITRATE: { + case TranscodePolicy.Bitrate: { return isRequired || isLargerThanTargetBitrate; } default: { @@ -630,12 +630,12 @@ export class MediaService extends BaseService { } private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { - if (ffmpegConfig.transcode === TranscodePolicy.DISABLED) { + if (ffmpegConfig.transcode === TranscodePolicy.Disabled) { return false; } - const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.MOV : (formatName as VideoContainer); - return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name); + const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.Mov : (formatName as VideoContainer); + return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name); } isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index b0ea697edb..ff88b9029c 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -12,7 +12,7 @@ const DAYS = 3; @Injectable() export class MemoryService extends BaseService { - @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.MemoriesCreate, queue: QueueName.BackgroundTask }) async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); const usersIds = await Promise.all( @@ -26,7 +26,7 @@ export class MemoryService extends BaseService { ); await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => { - const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); + const state = await this.systemMetadataRepository.get(SystemMetadataKey.MemoriesState); const start = DateTime.utc().startOf('day').minus({ days: DAYS }); const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; @@ -43,7 +43,7 @@ export class MemoryService extends BaseService { this.logger.error(`Failed to create memories for ${target.toISO()}`, error); } // update system metadata even when there is an error to minimize the chance of duplicates - await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { + await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, { ...state, lastOnThisDayDate: target.toISO(), }); @@ -60,7 +60,7 @@ export class MemoryService extends BaseService { this.memoryRepository.create( { ownerId, - type: MemoryType.ON_THIS_DAY, + type: MemoryType.OnThisDay, data: { year }, memoryAt: target.set({ year }).toISO()!, showAt, @@ -72,7 +72,7 @@ export class MemoryService extends BaseService { ); } - @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.MemoriesCleanup, queue: QueueName.BackgroundTask }) async onMemoriesCleanup() { await this.memoryRepository.cleanup(); } @@ -87,7 +87,7 @@ export class MemoryService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MemoryRead, ids: [id] }); const memory = await this.findOrFail(id); return mapMemory(memory, auth); } @@ -98,7 +98,7 @@ export class MemoryService extends BaseService { const assetIds = dto.assetIds || []; const allowedAssetIds = await this.checkAccess({ auth, - permission: Permission.ASSET_SHARE, + permission: Permission.AssetShare, ids: assetIds, }); const memory = await this.memoryRepository.create( @@ -117,7 +117,7 @@ export class MemoryService extends BaseService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MemoryUpdate, ids: [id] }); const memory = await this.memoryRepository.update(id, { isSaved: dto.isSaved, @@ -129,12 +129,12 @@ export class MemoryService extends BaseService { } async remove(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.MEMORY_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MemoryDelete, ids: [id] }); await this.memoryRepository.delete(id); } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.MEMORY_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MemoryRead, ids: [id] }); const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids }); @@ -148,13 +148,13 @@ export class MemoryService extends BaseService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.MEMORY_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.MemoryUpdate, ids: [id] }); const repos = { access: this.accessRepository, bulk: this.memoryRepository }; const results = await removeAssets(auth, repos, { parentId: id, assetIds: dto.ids, - canAlwaysRemove: Permission.MEMORY_DELETE, + canAlwaysRemove: Permission.MemoryDelete, }); const hasSuccess = results.find(({ success }) => success); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 881f25d5dd..23884256ea 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -50,7 +50,7 @@ describe(MetadataService.name, () => { mockReadTags(); - mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); delete process.env.TZ; }); @@ -102,11 +102,11 @@ describe(MetadataService.name, () => { it('should queue metadata extraction for all assets without exif values', async () => { mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); - await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.METADATA_EXTRACTION, + name: JobName.MetadataExtraction, data: { id: assetStub.image.id }, }, ]); @@ -115,11 +115,11 @@ describe(MetadataService.name, () => { it('should queue metadata extraction for all assets', async () => { mocks.assetJob.streamForMetadataExtraction.mockReturnValue(makeStream([assetStub.image])); - await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.METADATA_EXTRACTION, + name: JobName.MetadataExtraction, data: { id: assetStub.image.id }, }, ]); @@ -506,7 +506,7 @@ describe(MetadataService.name, () => { it('should not apply motion photos if asset is video', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); @@ -516,7 +516,7 @@ describe(MetadataService.name, () => { expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith( - expect.objectContaining({ assetType: AssetType.VIDEO, visibility: AssetVisibility.HIDDEN }), + expect.objectContaining({ assetType: AssetType.Video, visibility: AssetVisibility.Hidden }), ); }); @@ -583,13 +583,13 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, id: fileStub.livePhotoMotion.uuid, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4', ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, - type: AssetType.VIDEO, + type: AssetType.Video, }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); @@ -599,7 +599,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ - name: JobName.VIDEO_CONVERSION, + name: JobName.VideoConversation, data: { id: assetStub.livePhotoMotionAsset.id }, }); }); @@ -641,13 +641,13 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, id: fileStub.livePhotoMotion.uuid, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4', ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, - type: AssetType.VIDEO, + type: AssetType.Video, }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); @@ -657,7 +657,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ - name: JobName.VIDEO_CONVERSION, + name: JobName.VideoConversation, data: { id: assetStub.livePhotoMotionAsset.id }, }); }); @@ -699,13 +699,13 @@ describe(MetadataService.name, () => { fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, id: fileStub.livePhotoMotion.uuid, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4', ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, - type: AssetType.VIDEO, + type: AssetType.Video, }); expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); @@ -715,7 +715,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ - name: JobName.VIDEO_CONVERSION, + name: JobName.VideoConversation, data: { id: assetStub.livePhotoMotionAsset.id }, }); }); @@ -737,7 +737,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, }); }); @@ -778,7 +778,7 @@ describe(MetadataService.name, () => { mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.asset.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }); const video = randomBytes(512); mocks.storage.readFile.mockResolvedValue(video); @@ -786,7 +786,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, @@ -1106,7 +1106,7 @@ describe(MetadataService.name, () => { boundingBoxX2: 200, boundingBoxY1: 20, boundingBoxY2: 60, - sourceType: SourceType.EXIF, + sourceType: SourceType.Exif, }, ], [], @@ -1116,7 +1116,7 @@ describe(MetadataService.name, () => { ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: personStub.withName.id }, }, ]); @@ -1145,7 +1145,7 @@ describe(MetadataService.name, () => { boundingBoxX2: 200, boundingBoxY1: 20, boundingBoxY2: 60, - sourceType: SourceType.EXIF, + sourceType: SourceType.Exif, }, ], [], @@ -1234,7 +1234,7 @@ describe(MetadataService.name, () => { boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2, - sourceType: SourceType.EXIF, + sourceType: SourceType.Exif, }, ], [], @@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => { ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: personStub.withName.id }, }, ]); @@ -1308,7 +1308,7 @@ describe(MetadataService.name, () => { expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith( - expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), + expect.objectContaining({ visibility: AssetVisibility.Hidden }), ); expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled(); }); @@ -1326,10 +1326,10 @@ describe(MetadataService.name, () => { ownerId: assetStub.livePhotoMotionAsset.ownerId, otherAssetId: assetStub.livePhotoMotionAsset.id, libraryId: null, - type: AssetType.IMAGE, + type: AssetType.Image, }); expect(mocks.asset.update).not.toHaveBeenCalledWith( - expect.objectContaining({ visibility: AssetVisibility.HIDDEN }), + expect.objectContaining({ visibility: AssetVisibility.Hidden }), ); expect(mocks.album.removeAssetsFromAll).not.toHaveBeenCalled(); }); @@ -1346,7 +1346,7 @@ describe(MetadataService.name, () => { livePhotoCID: 'CID', ownerId: assetStub.livePhotoStillAsset.ownerId, otherAssetId: assetStub.livePhotoStillAsset.id, - type: AssetType.VIDEO, + type: AssetType.Video, }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, @@ -1354,7 +1354,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, }); expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); }); @@ -1457,7 +1457,7 @@ describe(MetadataService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.SIDECAR_SYNC, + name: JobName.SidecarSync, data: { id: assetStub.sidecar.id }, }, ]); @@ -1471,7 +1471,7 @@ describe(MetadataService.name, () => { expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.SIDECAR_DISCOVERY, + name: JobName.SidecarDiscovery, data: { id: assetStub.image.id }, }, ]); @@ -1481,13 +1481,13 @@ describe(MetadataService.name, () => { describe('handleSidecarSync', () => { it('should do nothing if asset could not be found', async () => { mocks.asset.getByIds.mockResolvedValue([]); - await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed); expect(mocks.asset.update).not.toHaveBeenCalled(); }); it('should do nothing if asset has no sidecar path', async () => { mocks.asset.getByIds.mockResolvedValue([assetStub.image]); - await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.Failed); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -1495,7 +1495,7 @@ describe(MetadataService.name, () => { mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.storage.checkFileExists.mockResolvedValue(true); - await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success); expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( `${assetStub.sidecar.originalPath}.xmp`, constants.R_OK, @@ -1511,7 +1511,7 @@ describe(MetadataService.name, () => { mocks.storage.checkFileExists.mockResolvedValueOnce(false); mocks.storage.checkFileExists.mockResolvedValueOnce(true); - await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.Success); expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, assetStub.sidecarWithoutExt.sidecarPath, @@ -1528,7 +1528,7 @@ describe(MetadataService.name, () => { mocks.storage.checkFileExists.mockResolvedValueOnce(true); mocks.storage.checkFileExists.mockResolvedValueOnce(true); - await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success); expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); expect(mocks.storage.checkFileExists).toHaveBeenNthCalledWith( 2, @@ -1545,7 +1545,7 @@ describe(MetadataService.name, () => { mocks.asset.getByIds.mockResolvedValue([assetStub.sidecar]); mocks.storage.checkFileExists.mockResolvedValue(false); - await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.Success); expect(mocks.storage.checkFileExists).toHaveBeenCalledWith( `${assetStub.sidecar.originalPath}.xmp`, constants.R_OK, @@ -1603,14 +1603,14 @@ describe(MetadataService.name, () => { describe('handleSidecarWrite', () => { it('should skip assets that do not exist anymore', async () => { mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0); - await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED); + await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); it('should skip jobs with no metadata', async () => { const asset = factory.jobAssets.sidecarWrite(); mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset); - await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.metadata.writeTags).not.toHaveBeenCalled(); }); @@ -1629,7 +1629,7 @@ describe(MetadataService.name, () => { longitude: gps, dateTimeOriginal: date, }), - ).resolves.toBe(JobStatus.SUCCESS); + ).resolves.toBe(JobStatus.Success); expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.sidecarPath, { Description: description, ImageDescription: description, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index ea3f810fa4..037bab3633 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -126,7 +126,7 @@ type Dates = { @Injectable() export class MetadataService extends BaseService { - @OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.MICROSERVICES] }) + @OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] }) async onBootstrap() { this.logger.log('Bootstrapping metadata service'); await this.init(); @@ -137,12 +137,12 @@ export class MetadataService extends BaseService { await this.metadataRepository.teardown(); } - @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.MICROSERVICES] }) + @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Microservices] }) onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) { this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); } - @OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.MICROSERVICES], server: true }) + @OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Microservices], server: true }) onConfigUpdate({ newConfig }: ArgOf<'ConfigUpdate'>) { this.metadataRepository.setMaxConcurrency(newConfig.job.metadataExtraction.concurrency); } @@ -151,9 +151,9 @@ export class MetadataService extends BaseService { this.logger.log('Initializing metadata service'); try { - await this.jobRepository.pause(QueueName.METADATA_EXTRACTION); + await this.jobRepository.pause(QueueName.MetadataExtraction); await this.databaseRepository.withLock(DatabaseLock.GeodataImport, () => this.mapRepository.init()); - await this.jobRepository.resume(QueueName.METADATA_EXTRACTION); + await this.jobRepository.resume(QueueName.MetadataExtraction); this.logger.log(`Initialized local reverse geocoder`); } catch (error: Error | any) { @@ -170,7 +170,7 @@ export class MetadataService extends BaseService { return; } - const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO; + const otherType = asset.type === AssetType.Video ? AssetType.Image : AssetType.Video; const match = await this.assetRepository.findLivePhotoMatch({ livePhotoCID: exifInfo.livePhotoCID, ownerId: asset.ownerId, @@ -183,23 +183,23 @@ export class MetadataService extends BaseService { return; } - const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; + const [photoAsset, motionAsset] = asset.type === AssetType.Image ? [asset, match] : [match, asset]; await Promise.all([ this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }), - this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }), + this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.Hidden }), this.albumRepository.removeAssetsFromAll([motionAsset.id]), ]); await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); } - @OnJob({ name: JobName.QUEUE_METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) - async handleQueueMetadataExtraction(job: JobOf): Promise { + @OnJob({ name: JobName.QueueMetadataExtraction, queue: QueueName.MetadataExtraction }) + async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; - let queue: { name: JobName.METADATA_EXTRACTION; data: { id: string } }[] = []; + let queue: { name: JobName.MetadataExtraction; data: { id: string } }[] = []; for await (const asset of this.assetJobRepository.streamForMetadataExtraction(force)) { - queue.push({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id } }); + queue.push({ name: JobName.MetadataExtraction, data: { id: asset.id } }); if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(queue); @@ -208,11 +208,11 @@ export class MetadataService extends BaseService { } await this.jobRepository.queueAll(queue); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) - async handleMetadataExtraction(data: JobOf) { + @OnJob({ name: JobName.MetadataExtraction, queue: QueueName.MetadataExtraction }) + async handleMetadataExtraction(data: JobOf) { const [{ metadata, reverseGeocoding }, asset] = await Promise.all([ this.getConfig({ withCache: true }), this.assetJobRepository.getForMetadataExtraction(data.id), @@ -320,8 +320,8 @@ export class MetadataService extends BaseService { }); } - @OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR }) - async handleQueueSidecar({ force }: JobOf): Promise { + @OnJob({ name: JobName.QueueSidecar, queue: QueueName.Sidecar }) + async handleQueueSidecar({ force }: JobOf): Promise { let jobs: JobItem[] = []; const queueAll = async () => { await this.jobRepository.queueAll(jobs); @@ -330,7 +330,7 @@ export class MetadataService extends BaseService { const assets = this.assetJobRepository.streamForSidecar(force); for await (const asset of assets) { - jobs.push({ name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, data: { id: asset.id } }); + jobs.push({ name: force ? JobName.SidecarSync : JobName.SidecarDiscovery, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -338,35 +338,35 @@ export class MetadataService extends BaseService { await queueAll(); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.SIDECAR_SYNC, queue: QueueName.SIDECAR }) - handleSidecarSync({ id }: JobOf): Promise { + @OnJob({ name: JobName.SidecarSync, queue: QueueName.Sidecar }) + handleSidecarSync({ id }: JobOf): Promise { return this.processSidecar(id, true); } - @OnJob({ name: JobName.SIDECAR_DISCOVERY, queue: QueueName.SIDECAR }) - handleSidecarDiscovery({ id }: JobOf): Promise { + @OnJob({ name: JobName.SidecarDiscovery, queue: QueueName.Sidecar }) + handleSidecarDiscovery({ id }: JobOf): Promise { return this.processSidecar(id, false); } @OnEvent({ name: 'AssetTag' }) async handleTagAsset({ assetId }: ArgOf<'AssetTag'>) { - await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } }); } @OnEvent({ name: 'AssetUntag' }) async handleUntagAsset({ assetId }: ArgOf<'AssetUntag'>) { - await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); + await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id: assetId, tags: true } }); } - @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR }) - async handleSidecarWrite(job: JobOf): Promise { + @OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar }) + async handleSidecarWrite(job: JobOf): Promise { const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; const asset = await this.assetJobRepository.getForSidecarWriteJob(id); if (!asset) { - return JobStatus.FAILED; + return JobStatus.Failed; } const tagsList = (asset.tags || []).map((tag) => tag.value); @@ -386,7 +386,7 @@ export class MetadataService extends BaseService { ); if (Object.keys(exif).length === 0) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } await this.metadataRepository.writeTags(sidecarPath, exif); @@ -395,7 +395,7 @@ export class MetadataService extends BaseService { await this.assetRepository.update({ id, sidecarPath }); } - return JobStatus.SUCCESS; + return JobStatus.Success; } private getImageDimensions(exifTags: ImmichTags): { width?: number; height?: number } { @@ -416,7 +416,7 @@ export class MetadataService extends BaseService { sidecarPath: string | null; type: AssetType; }): Promise { - if (!asset.sidecarPath && asset.type === AssetType.IMAGE) { + if (!asset.sidecarPath && asset.type === AssetType.Image) { return this.metadataRepository.readTags(asset.originalPath); } @@ -431,7 +431,7 @@ export class MetadataService extends BaseService { const [mediaTags, sidecarTags, videoTags] = await Promise.all([ this.metadataRepository.readTags(asset.originalPath), asset.sidecarPath ? this.metadataRepository.readTags(asset.sidecarPath) : null, - asset.type === AssetType.VIDEO ? this.getVideoTags(asset.originalPath) : null, + asset.type === AssetType.Video ? this.getVideoTags(asset.originalPath) : null, ]); // prefer dates from sidecar tags @@ -488,7 +488,7 @@ export class MetadataService extends BaseService { } private isMotionPhoto(asset: { type: AssetType }, tags: ImmichTags): boolean { - return asset.type === AssetType.IMAGE && !!(tags.MotionPhoto || tags.MicroVideo); + return asset.type === AssetType.Image && !!(tags.MotionPhoto || tags.MicroVideo); } private async applyMotionPhotos(asset: Asset, tags: ImmichTags, dates: Dates, stats: Stats) { @@ -558,10 +558,10 @@ export class MetadataService extends BaseService { }); // Hide the motion photo video asset if it's not already hidden to prepare for linking - if (motionAsset.visibility === AssetVisibility.TIMELINE) { + if (motionAsset.visibility === AssetVisibility.Timeline) { await this.assetRepository.update({ id: motionAsset.id, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, }); this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`); } @@ -570,7 +570,7 @@ export class MetadataService extends BaseService { motionAsset = await this.assetRepository.create({ id: motionAssetId, libraryId: asset.libraryId, - type: AssetType.VIDEO, + type: AssetType.Video, fileCreatedAt: dates.dateTimeOriginal, fileModifiedAt: stats.mtime, localDateTime: dates.localDateTime, @@ -578,7 +578,7 @@ export class MetadataService extends BaseService { ownerId: asset.ownerId, originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId), originalFileName: `${path.parse(asset.originalFileName).name}.mp4`, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, deviceAssetId: 'NONE', deviceId: 'NONE', }); @@ -597,7 +597,7 @@ export class MetadataService extends BaseService { // note asset.livePhotoVideoId is not motionAsset.id yet if (asset.livePhotoVideoId) { await this.jobRepository.queue({ - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id: asset.livePhotoVideoId, deleteOnDisk: true }, }); this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`); @@ -612,7 +612,7 @@ export class MetadataService extends BaseService { this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.handleMetadataExtraction({ id: motionAsset.id }); - await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: motionAsset.id } }); + await this.jobRepository.queue({ name: JobName.VideoConversation, data: { id: motionAsset.id } }); } this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); @@ -740,7 +740,7 @@ export class MetadataService extends BaseService { boundingBoxY1: Math.floor((region.Area.Y - region.Area.H / 2) * imageHeight), boundingBoxX2: Math.floor((region.Area.X + region.Area.W / 2) * imageWidth), boundingBoxY2: Math.floor((region.Area.Y + region.Area.H / 2) * imageHeight), - sourceType: SourceType.EXIF, + sourceType: SourceType.Exif, }; facesToAdd.push(face); @@ -753,11 +753,11 @@ export class MetadataService extends BaseService { if (missing.length > 0) { this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); const newPersonIds = await this.personRepository.createAll(missing); - const jobs = newPersonIds.map((id) => ({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }) as const); + const jobs = newPersonIds.map((id) => ({ name: JobName.GeneratePersonThumbnail, data: { id } }) as const); await this.jobRepository.queueAll(jobs); } - const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.EXIF).map((face) => face.id); + const facesToRemove = asset.faces.filter((face) => face.sourceType === SourceType.Exif).map((face) => face.id); if (facesToRemove.length > 0) { this.logger.debug(`Removing ${facesToRemove.length} faces for asset ${asset.id}: ${asset.originalPath}`); } @@ -894,15 +894,15 @@ export class MetadataService extends BaseService { const [asset] = await this.assetRepository.getByIds([id]); if (!asset) { - return JobStatus.FAILED; + return JobStatus.Failed; } if (isSync && !asset.sidecarPath) { - return JobStatus.FAILED; + return JobStatus.Failed; } - if (!isSync && (asset.visibility === AssetVisibility.HIDDEN || asset.sidecarPath) && !asset.isExternal) { - return JobStatus.FAILED; + if (!isSync && (asset.visibility === AssetVisibility.Hidden || asset.sidecarPath) && !asset.isExternal) { + return JobStatus.Failed; } // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp @@ -927,22 +927,22 @@ export class MetadataService extends BaseService { if (sidecarPath !== asset.sidecarPath) { await this.assetRepository.update({ id: asset.id, sidecarPath }); } - return JobStatus.SUCCESS; + return JobStatus.Success; } if (sidecarPath) { this.logger.debug(`Detected sidecar at '${sidecarPath}' for asset ${asset.id}: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, sidecarPath }); - return JobStatus.SUCCESS; + return JobStatus.Success; } if (!isSync) { - return JobStatus.FAILED; + return JobStatus.Failed; } this.logger.debug(`No sidecar found for asset ${asset.id}: ${asset.originalPath}`); await this.assetRepository.update({ id: asset.id, sidecarPath: null }); - return JobStatus.SUCCESS; + return JobStatus.Success; } } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index bca7074194..97e5ddf106 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -131,7 +131,7 @@ describe(NotificationService.name, () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.GENERATE_THUMBNAILS, + name: JobName.GenerateThumbnails, data: { id: 'asset-id', notify: true }, }); }); @@ -146,7 +146,7 @@ describe(NotificationService.name, () => { it('should queue notify signup event if notify is true', async () => { await sut.onUserSignup({ id: '', notify: true }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.NOTIFY_SIGNUP, + name: JobName.NotifySignup, data: { id: '', tempPassword: undefined }, }); }); @@ -156,7 +156,7 @@ describe(NotificationService.name, () => { it('should queue notify album update event', async () => { await sut.onAlbumUpdate({ id: 'album', recipientId: '42' }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.NOTIFY_ALBUM_UPDATE, + name: JobName.NotifyAlbumUpdate, data: { id: 'album', recipientId: '42', delay: 300_000 }, }); }); @@ -166,7 +166,7 @@ describe(NotificationService.name, () => { it('should queue notify album invite event', async () => { await sut.onAlbumInvite({ id: '', userId: '42' }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.NOTIFY_ALBUM_INVITE, + name: JobName.NotifyAlbumInvite, data: { id: '', recipientId: '42' }, }); }); @@ -242,7 +242,7 @@ describe(NotificationService.name, () => { describe('handleUserSignup', () => { it('should skip if user could not be found', async () => { - await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.Skipped); }); it('should be successful', async () => { @@ -250,9 +250,9 @@ describe(NotificationService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleUserSignup({ id: '' })).resolves.toBe(JobStatus.Success); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: expect.objectContaining({ subject: 'Welcome to Immich' }), }); }); @@ -260,14 +260,14 @@ describe(NotificationService.name, () => { describe('handleAlbumInvite', () => { it('should skip if album could not be found', async () => { - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if recipient could not be found', async () => { mocks.album.getById.mockResolvedValue(albumStub.empty); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queue).not.toHaveBeenCalled(); }); @@ -277,13 +277,13 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: false, albumInvite: true } }, }, ], }); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); }); it('should skip if the recipient has email notifications for album invite disabled', async () => { @@ -292,13 +292,13 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: true, albumInvite: false } }, }, ], }); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped); }); it('should send invite email', async () => { @@ -307,7 +307,7 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: true, albumInvite: true } }, }, ], @@ -315,9 +315,9 @@ describe(NotificationService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album') }), }); }); @@ -328,7 +328,7 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: true, albumInvite: true } }, }, ], @@ -337,13 +337,13 @@ describe(NotificationService.name, () => { mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, - AssetFileType.THUMBNAIL, + AssetFileType.Thumbnail, ); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), imageAttachments: undefined, @@ -357,7 +357,7 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: true, albumInvite: true } }, }, ], @@ -365,16 +365,16 @@ describe(NotificationService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ server: {} }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([ - { id: '1', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' }, + { id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' }, ]); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, - AssetFileType.THUMBNAIL, + AssetFileType.Thumbnail, ); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), imageAttachments: [{ filename: 'album-thumbnail.jpg', path: expect.anything(), cid: expect.anything() }], @@ -388,7 +388,7 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: true, albumInvite: true } }, }, ], @@ -397,13 +397,13 @@ describe(NotificationService.name, () => { mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]); - await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith( albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, - AssetFileType.THUMBNAIL, + AssetFileType.Thumbnail, ); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: expect.objectContaining({ subject: expect.stringContaining('You have been added to a shared album'), imageAttachments: [{ filename: 'album-thumbnail.ext', path: expect.anything(), cid: expect.anything() }], @@ -414,14 +414,14 @@ describe(NotificationService.name, () => { describe('handleAlbumUpdate', () => { it('should skip if album could not be found', async () => { - await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped); expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should skip if owner could not be found', async () => { mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); - await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped); expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); }); @@ -448,7 +448,7 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: false, albumUpdate: true } }, }, ], @@ -470,7 +470,7 @@ describe(NotificationService.name, () => { ...userStub.user1, metadata: [ { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: { emailNotifications: { enabled: true, albumUpdate: false } }, }, ], @@ -500,9 +500,9 @@ describe(NotificationService.name, () => { it('should add new recipients for new images if job is already queued', async () => { await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob); - expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2'); + expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NotifyAlbumUpdate, '1/2'); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.NOTIFY_ALBUM_UPDATE, + name: JobName.NotifyAlbumUpdate, data: { id: '1', delay: 300_000, @@ -515,7 +515,7 @@ describe(NotificationService.name, () => { describe('handleSendEmail', () => { it('should skip if smtp notifications are disabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ notifications: { smtp: { enabled: false } } }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.Skipped); }); it('should send mail successfully', async () => { @@ -524,7 +524,7 @@ describe(NotificationService.name, () => { }); mocks.email.sendEmail.mockResolvedValue({ messageId: '', response: '' }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.Success); expect(mocks.email.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'test@immich.app' })); }); @@ -534,7 +534,7 @@ describe(NotificationService.name, () => { }); mocks.email.sendEmail.mockResolvedValue({ messageId: '', response: '' }); - await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleSendEmail({ html: '', subject: '', text: '', to: '' })).resolves.toBe(JobStatus.Success); expect(mocks.email.sendEmail).toHaveBeenCalledWith(expect.objectContaining({ replyTo: 'demo@immich.app' })); }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 80a20195a1..90a3b63ac5 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -39,19 +39,19 @@ export class NotificationService extends BaseService { } async updateAll(auth: AuthDto, dto: NotificationUpdateAllDto) { - await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_UPDATE }); + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NotificationUpdate }); await this.notificationRepository.updateAll(dto.ids, { readAt: dto.readAt, }); } async deleteAll(auth: AuthDto, dto: NotificationDeleteAllDto) { - await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NOTIFICATION_DELETE }); + await this.requireAccess({ auth, ids: dto.ids, permission: Permission.NotificationDelete }); await this.notificationRepository.deleteAll(dto.ids); } async get(auth: AuthDto, id: string) { - await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_READ }); + await this.requireAccess({ auth, ids: [id], permission: Permission.NotificationRead }); const item = await this.notificationRepository.get(id); if (!item) { throw new BadRequestException('Notification not found'); @@ -60,7 +60,7 @@ export class NotificationService extends BaseService { } async update(auth: AuthDto, id: string, dto: NotificationUpdateDto) { - await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_UPDATE }); + await this.requireAccess({ auth, ids: [id], permission: Permission.NotificationUpdate }); const item = await this.notificationRepository.update(id, { readAt: dto.readAt, }); @@ -68,11 +68,11 @@ export class NotificationService extends BaseService { } async delete(auth: AuthDto, id: string) { - await this.requireAccess({ auth, ids: [id], permission: Permission.NOTIFICATION_DELETE }); + await this.requireAccess({ auth, ids: [id], permission: Permission.NotificationDelete }); await this.notificationRepository.delete(id); } - @OnJob({ name: JobName.NOTIFICATIONS_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.NotificationsCleanup, queue: QueueName.BackgroundTask }) async onNotificationsCleanup() { await this.notificationRepository.cleanup(); } @@ -87,7 +87,7 @@ export class NotificationService extends BaseService { this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); switch (job.name) { - case JobName.BACKUP_DATABASE: { + case JobName.BackupDatabase: { const errorMessage = error instanceof Error ? error.message : error; const item = await this.notificationRepository.create({ userId: admin.id, @@ -135,7 +135,7 @@ export class NotificationService extends BaseService { @OnEvent({ name: 'AssetShow' }) async onAssetShow({ assetId }: ArgOf<'AssetShow'>) { - await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: { id: assetId, notify: true } }); + await this.jobRepository.queue({ name: JobName.GenerateThumbnails, data: { id: assetId, notify: true } }); } @OnEvent({ name: 'AssetTrash' }) @@ -193,22 +193,22 @@ export class NotificationService extends BaseService { @OnEvent({ name: 'UserSignup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'UserSignup'>) { if (notify) { - await this.jobRepository.queue({ name: JobName.NOTIFY_SIGNUP, data: { id, tempPassword } }); + await this.jobRepository.queue({ name: JobName.NotifySignup, data: { id, tempPassword } }); } } @OnEvent({ name: 'AlbumUpdate' }) async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) { - await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`); + await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`); await this.jobRepository.queue({ - name: JobName.NOTIFY_ALBUM_UPDATE, + name: JobName.NotifyAlbumUpdate, data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs }, }); } @OnEvent({ name: 'AlbumInvite' }) async onAlbumInvite({ id, userId }: ArgOf<'AlbumInvite'>) { - await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); + await this.jobRepository.queue({ name: JobName.NotifyAlbumInvite, data: { id, recipientId: userId } }); } @OnEvent({ name: 'SessionDelete' }) @@ -313,11 +313,11 @@ export class NotificationService extends BaseService { return { name, html: templateResponse }; } - @OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION }) - async handleUserSignup({ id, tempPassword }: JobOf) { + @OnJob({ name: JobName.NotifySignup, queue: QueueName.Notification }) + async handleUserSignup({ id, tempPassword }: JobOf) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const { server, templates } = await this.getConfig({ withCache: true }); @@ -333,7 +333,7 @@ export class NotificationService extends BaseService { }); await this.jobRepository.queue({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: { to: user.email, subject: 'Welcome to Immich', @@ -342,25 +342,25 @@ export class NotificationService extends BaseService { }, }); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.NOTIFY_ALBUM_INVITE, queue: QueueName.NOTIFICATION }) - async handleAlbumInvite({ id, recipientId }: JobOf) { + @OnJob({ name: JobName.NotifyAlbumInvite, queue: QueueName.Notification }) + async handleAlbumInvite({ id, recipientId }: JobOf) { const album = await this.albumRepository.getById(id, { withAssets: false }); if (!album) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const recipient = await this.userRepository.get(recipientId, { withDeleted: false }); if (!recipient) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const { emailNotifications } = getPreferences(recipient.metadata); if (!emailNotifications.enabled || !emailNotifications.albumInvite) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const attachment = await this.getAlbumThumbnailAttachment(album); @@ -380,7 +380,7 @@ export class NotificationService extends BaseService { }); await this.jobRepository.queue({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: { to: recipient.email, subject: `You have been added to a shared album - ${album.albumName}`, @@ -390,20 +390,20 @@ export class NotificationService extends BaseService { }, }); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION }) - async handleAlbumUpdate({ id, recipientId }: JobOf) { + @OnJob({ name: JobName.NotifyAlbumUpdate, queue: QueueName.Notification }) + async handleAlbumUpdate({ id, recipientId }: JobOf) { const album = await this.albumRepository.getById(id, { withAssets: false }); if (!album) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const owner = await this.userRepository.get(album.ownerId, { withDeleted: false }); if (!owner) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const attachment = await this.getAlbumThumbnailAttachment(album); @@ -412,13 +412,13 @@ export class NotificationService extends BaseService { const user = await this.userRepository.get(recipientId, { withDeleted: false }); if (!user) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const { emailNotifications } = getPreferences(user.metadata); if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const { html, text } = await this.emailRepository.renderEmail({ @@ -434,7 +434,7 @@ export class NotificationService extends BaseService { }); await this.jobRepository.queue({ - name: JobName.SEND_EMAIL, + name: JobName.SendMail, data: { to: user.email, subject: `New media has been added to an album - ${album.albumName}`, @@ -444,14 +444,14 @@ export class NotificationService extends BaseService { }, }); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.SEND_EMAIL, queue: QueueName.NOTIFICATION }) - async handleSendEmail(data: JobOf): Promise { + @OnJob({ name: JobName.SendMail, queue: QueueName.Notification }) + async handleSendEmail(data: JobOf): Promise { const { notifications } = await this.getConfig({ withCache: false }); if (!notifications.smtp.enabled) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const { to, subject, html, text: plain } = data; @@ -468,7 +468,7 @@ export class NotificationService extends BaseService { this.logger.log(`Sent mail with id: ${response.messageId} status: ${response.response}`); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async getAlbumThumbnailAttachment(album: { @@ -480,7 +480,7 @@ export class NotificationService extends BaseService { const albumThumbnailFiles = await this.assetJobRepository.getAlbumThumbnailFiles( album.albumThumbnailAssetId, - AssetFileType.THUMBNAIL, + AssetFileType.Thumbnail, ); if (albumThumbnailFiles.length !== 1) { diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 3723634948..755b688397 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -40,7 +40,7 @@ export class PartnerService extends BaseService { } async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise { - await this.requireAccess({ auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] }); + await this.requireAccess({ auth, permission: Permission.PartnerUpdate, ids: [sharedById] }); const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id }; const entity = await this.partnerRepository.update(partnerId, { inTimeline: dto.inTimeline }); diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index d9df2225f4..8fbf8b20dd 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -182,7 +182,7 @@ describe(PersonService.name, () => { new ImmichFileResponse({ path: '/path/to/thumbnail.jpg', contentType: 'image/jpeg', - cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE, + cacheControl: CacheControl.PrivateWithoutCache, }), ); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); @@ -276,7 +276,7 @@ describe(PersonService.name, () => { }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: 'person-1' }, }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); @@ -337,7 +337,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: personStub.newThumbnail.id }, }, ]); @@ -346,7 +346,7 @@ describe(PersonService.name, () => { describe('handlePersonMigration', () => { it('should not move person files', async () => { - await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED); + await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.Failed); }); }); @@ -373,7 +373,7 @@ describe(PersonService.name, () => { await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GENERATE_PERSON_THUMBNAIL, + name: JobName.GeneratePersonThumbnail, data: { id: personStub.newThumbnail.id }, }, ]); @@ -447,7 +447,7 @@ describe(PersonService.name, () => { it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled(); @@ -462,7 +462,7 @@ describe(PersonService.name, () => { expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACE_DETECTION, + name: JobName.FaceDetection, data: { id: assetStub.image.id }, }, ]); @@ -474,14 +474,14 @@ describe(PersonService.name, () => { await sut.handleQueueDetectFaces({ force: true }); - expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning }); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true }); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACE_DETECTION, + name: JobName.FaceDetection, data: { id: assetStub.image.id }, }, ]); @@ -499,11 +499,11 @@ describe(PersonService.name, () => { expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACE_DETECTION, + name: JobName.FaceDetection, data: { id: assetStub.image.id }, }, ]); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PERSON_CLEANUP }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.PersonCleanup }); }); it('should delete existing people and faces if forced', async () => { @@ -518,7 +518,7 @@ describe(PersonService.name, () => { expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACE_DETECTION, + name: JobName.FaceDetection, data: { id: assetStub.image.id }, }, ]); @@ -540,7 +540,7 @@ describe(PersonService.name, () => { }); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled(); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); @@ -556,7 +556,7 @@ describe(PersonService.name, () => { delayed: 0, }); - await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.Skipped); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); }); @@ -577,15 +577,15 @@ describe(PersonService.name, () => { expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ personId: null, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACIAL_RECOGNITION, + name: JobName.FacialRecognition, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { lastRun: expect.any(String), }); expect(mocks.person.vacuum).not.toHaveBeenCalled(); @@ -609,11 +609,11 @@ describe(PersonService.name, () => { expect(mocks.person.getAllFaces).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACIAL_RECOGNITION, + name: JobName.FacialRecognition, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { lastRun: expect.any(String), }); expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false }); @@ -637,19 +637,19 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: false, nightly: true }); - expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState); expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ personId: null, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACIAL_RECOGNITION, + name: JobName.FacialRecognition, data: { id: faceStub.face1.id, deferred: false }, }, ]); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState, { lastRun: expect.any(String), }); expect(mocks.person.vacuum).not.toHaveBeenCalled(); @@ -665,7 +665,7 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true, nightly: true }); - expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FACIAL_RECOGNITION_STATE); + expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState); expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); expect(mocks.person.getAllFaces).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); @@ -690,10 +690,10 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({ force: true }); expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); - expect(mocks.person.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); + expect(mocks.person.unassignFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FACIAL_RECOGNITION, + name: JobName.FacialRecognition, data: { id: faceStub.face1.id, deferred: false }, }, ]); @@ -711,7 +711,7 @@ describe(PersonService.name, () => { it('should skip if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.Skipped); expect(mocks.asset.getByIds).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).toHaveBeenCalled(); }); @@ -754,8 +754,8 @@ describe(PersonService.name, () => { expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, - { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, + { name: JobName.QueueFacialRecognition, data: { force: false } }, + { name: JobName.FacialRecognition, data: { id: faceId } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); @@ -790,8 +790,8 @@ describe(PersonService.name, () => { expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, - { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, + { name: JobName.QueueFacialRecognition, data: { force: false } }, + { name: JobName.FacialRecognition, data: { id: faceId } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); @@ -830,8 +830,8 @@ describe(PersonService.name, () => { expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, - { name: JobName.FACIAL_RECOGNITION, data: { id: faceId } }, + { name: JobName.QueueFacialRecognition, data: { force: false } }, + { name: JobName.FacialRecognition, data: { id: faceId } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); @@ -840,7 +840,7 @@ describe(PersonService.name, () => { describe('handleRecognizeFaces', () => { it('should fail if face does not exist', async () => { - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); + expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); @@ -850,7 +850,7 @@ describe(PersonService.name, () => { const face = { ...faceStub.face1, asset: null }; mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(face); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED); + expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Failed); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); @@ -859,7 +859,7 @@ describe(PersonService.name, () => { it('should skip if face already has an assigned person', async () => { mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(faceStub.face1); - expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED); + expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.Skipped); expect(mocks.person.reassignFaces).not.toHaveBeenCalled(); expect(mocks.person.create).not.toHaveBeenCalled(); @@ -1008,7 +1008,7 @@ describe(PersonService.name, () => { await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.FACIAL_RECOGNITION, + name: JobName.FacialRecognition, data: { id: faceStub.noPerson1.id, deferred: true }, }); expect(mocks.search.searchFaces).toHaveBeenCalledTimes(1); @@ -1161,7 +1161,7 @@ describe(PersonService.name, () => { id: faceStub.face1.id, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, person: mapPerson(personStub.withName), }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index af34e6eda9..d8925578d4 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -78,7 +78,7 @@ export class PersonService extends BaseService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); const person = await this.findOrFail(personId); const result: PersonResponseDto[] = []; const changeFeaturePhoto: string[] = []; @@ -86,7 +86,7 @@ export class PersonService extends BaseService { const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); for (const face of faces) { - await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [face.id] }); + await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] }); if (person.faceAssetId === null) { changeFeaturePhoto.push(person.id); } @@ -107,8 +107,8 @@ export class PersonService extends BaseService { } async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [personId] }); - await this.requireAccess({ auth, permission: Permission.PERSON_CREATE, ids: [dto.id] }); + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [personId] }); + await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [dto.id] }); const face = await this.personRepository.getFaceById(dto.id); const person = await this.findOrFail(personId); @@ -124,7 +124,7 @@ export class PersonService extends BaseService { } async getFacesById(auth: AuthDto, dto: FaceDto): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.id] }); + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); const faces = await this.personRepository.getFaces(dto.id); return faces.map((asset) => mapFaces(asset, auth)); } @@ -140,7 +140,7 @@ export class PersonService extends BaseService { if (assetFace) { await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); - jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } }); + jobs.push({ name: JobName.GeneratePersonThumbnail, data: { id: personId } }); } } @@ -148,17 +148,17 @@ export class PersonService extends BaseService { } async getById(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); return this.findOrFail(id).then(mapPerson); } async getStatistics(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); return this.personRepository.getStatistics(id); } async getThumbnail(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); const person = await this.personRepository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); @@ -167,7 +167,7 @@ export class PersonService extends BaseService { return new ImmichFileResponse({ path: person.thumbnailPath, contentType: mimeTypes.lookup(person.thumbnailPath), - cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE, + cacheControl: CacheControl.PrivateWithoutCache, }); } @@ -185,13 +185,13 @@ export class PersonService extends BaseService { } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { - await this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [assetId] }); + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [assetId] }); const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]); if (!face) { throw new BadRequestException('Invalid assetId for feature face'); @@ -211,7 +211,7 @@ export class PersonService extends BaseService { }); if (assetId) { - await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); + await this.jobRepository.queue({ name: JobName.GeneratePersonThumbnail, data: { id } }); } return mapPerson(person); @@ -242,7 +242,7 @@ export class PersonService extends BaseService { } async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.PERSON_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.PersonDelete, ids }); const people = await this.personRepository.getForPeopleDelete(ids); await this.removeAllPeople(people); } @@ -254,22 +254,22 @@ export class PersonService extends BaseService { this.logger.debug(`Deleted ${people.length} people`); } - @OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask }) async handlePersonCleanup(): Promise { const people = await this.personRepository.getAllWithoutFaces(); await this.removeAllPeople(people); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.QUEUE_FACE_DETECTION, queue: QueueName.FACE_DETECTION }) - async handleQueueDetectFaces({ force }: JobOf): Promise { + @OnJob({ name: JobName.QueueFaceDetection, queue: QueueName.FaceDetection }) + async handleQueueDetectFaces({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } if (force) { - await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.personRepository.deleteFaces({ sourceType: SourceType.MachineLearning }); await this.handlePersonCleanup(); await this.personRepository.vacuum({ reindexVectors: true }); } @@ -277,7 +277,7 @@ export class PersonService extends BaseService { let jobs: JobItem[] = []; const assets = this.assetJobRepository.streamForDetectFacesJob(force); for await (const asset of assets) { - jobs.push({ name: JobName.FACE_DETECTION, data: { id: asset.id } }); + jobs.push({ name: JobName.FaceDetection, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); @@ -288,27 +288,27 @@ export class PersonService extends BaseService { await this.jobRepository.queueAll(jobs); if (force === undefined) { - await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); + await this.jobRepository.queue({ name: JobName.PersonCleanup }); } - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.FACE_DETECTION, queue: QueueName.FACE_DETECTION }) - async handleDetectFaces({ id }: JobOf): Promise { + @OnJob({ name: JobName.FaceDetection, queue: QueueName.FaceDetection }) + async handleDetectFaces({ id }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const asset = await this.assetJobRepository.getForDetectFacesJob(id); const previewFile = asset?.files[0]; if (!asset || asset.files.length !== 1 || !previewFile) { - return JobStatus.FAILED; + return JobStatus.Failed; } - if (asset.visibility === AssetVisibility.HIDDEN) { - return JobStatus.SKIPPED; + if (asset.visibility === AssetVisibility.Hidden) { + return JobStatus.Skipped; } const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( @@ -323,7 +323,7 @@ export class PersonService extends BaseService { const mlFaceIds = new Set(); for (const face of asset.faces) { - if (face.sourceType === SourceType.MACHINE_LEARNING) { + if (face.sourceType === SourceType.MachineLearning) { mlFaceIds.add(face.id); } } @@ -368,15 +368,15 @@ export class PersonService extends BaseService { if (facesToAdd.length > 0) { this.logger.log(`Detected ${facesToAdd.length} new faces in asset ${id}`); - const jobs = facesToAdd.map((face) => ({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id } }) as const); - await this.jobRepository.queueAll([{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, ...jobs]); + const jobs = facesToAdd.map((face) => ({ name: JobName.FacialRecognition, data: { id: face.id } }) as const); + await this.jobRepository.queueAll([{ name: JobName.QueueFacialRecognition, data: { force: false } }, ...jobs]); } else if (embeddings.length > 0) { this.logger.log(`Added ${embeddings.length} face embeddings for asset ${id}`); } await this.assetRepository.upsertJobStatus({ assetId: asset.id, facesRecognizedAt: new Date() }); - return JobStatus.SUCCESS; + return JobStatus.Success; } private iou( @@ -396,50 +396,50 @@ export class PersonService extends BaseService { return intersection / union; } - @OnJob({ name: JobName.QUEUE_FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION }) - async handleQueueRecognizeFaces({ force, nightly }: JobOf): Promise { + @OnJob({ name: JobName.QueueFacialRecognition, queue: QueueName.FacialRecognition }) + async handleQueueRecognizeFaces({ force, nightly }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } - await this.jobRepository.waitForQueueCompletion(QueueName.THUMBNAIL_GENERATION, QueueName.FACE_DETECTION); + await this.jobRepository.waitForQueueCompletion(QueueName.ThumbnailGeneration, QueueName.FaceDetection); if (nightly) { const [state, latestFaceDate] = await Promise.all([ - this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE), + this.systemMetadataRepository.get(SystemMetadataKey.FacialRecognitionState), this.personRepository.getLatestFaceDate(), ]); if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) { this.logger.debug('Skipping facial recognition nightly since no face has been added since the last run'); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } } - const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION); + const { waiting } = await this.jobRepository.getJobCounts(QueueName.FacialRecognition); if (force) { - await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING }); + await this.personRepository.unassignFaces({ sourceType: SourceType.MachineLearning }); await this.handlePersonCleanup(); await this.personRepository.vacuum({ reindexVectors: false }); } else if (waiting) { this.logger.debug( `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, ); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } - await this.databaseRepository.prewarm(VectorIndex.FACE); + await this.databaseRepository.prewarm(VectorIndex.Face); const lastRun = new Date().toISOString(); const facePagination = this.personRepository.getAllFaces( - force ? undefined : { personId: null, sourceType: SourceType.MACHINE_LEARNING }, + force ? undefined : { personId: null, sourceType: SourceType.MachineLearning }, ); - let jobs: { name: JobName.FACIAL_RECOGNITION; data: { id: string; deferred: false } }[] = []; + let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = []; for await (const face of facePagination) { - jobs.push({ name: JobName.FACIAL_RECOGNITION, data: { id: face.id, deferred: false } }); + jobs.push({ name: JobName.FacialRecognition, data: { id: face.id, deferred: false } }); if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); @@ -449,37 +449,37 @@ export class PersonService extends BaseService { await this.jobRepository.queueAll(jobs); - await this.systemMetadataRepository.set(SystemMetadataKey.FACIAL_RECOGNITION_STATE, { lastRun }); + await this.systemMetadataRepository.set(SystemMetadataKey.FacialRecognitionState, { lastRun }); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION }) - async handleRecognizeFaces({ id, deferred }: JobOf): Promise { + @OnJob({ name: JobName.FacialRecognition, queue: QueueName.FacialRecognition }) + async handleRecognizeFaces({ id, deferred }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const face = await this.personRepository.getFaceForFacialRecognitionJob(id); if (!face || !face.asset) { this.logger.warn(`Face ${id} not found`); - return JobStatus.FAILED; + return JobStatus.Failed; } - if (face.sourceType !== SourceType.MACHINE_LEARNING) { + if (face.sourceType !== SourceType.MachineLearning) { this.logger.warn(`Skipping face ${id} due to source ${face.sourceType}`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } if (!face.faceSearch?.embedding) { this.logger.warn(`Face ${id} does not have an embedding`); - return JobStatus.FAILED; + return JobStatus.Failed; } if (face.personId) { this.logger.debug(`Face ${id} already has a person assigned`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const matches = await this.searchRepository.searchFaces({ @@ -493,18 +493,18 @@ export class PersonService extends BaseService { // `matches` also includes the face itself if (machineLearning.facialRecognition.minFaces > 1 && matches.length <= 1) { this.logger.debug(`Face ${id} only matched the face itself, skipping`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } this.logger.debug(`Face ${id} has ${matches.length} matches`); const isCore = matches.length >= machineLearning.facialRecognition.minFaces && - face.asset.visibility === AssetVisibility.TIMELINE; + face.asset.visibility === AssetVisibility.Timeline; if (!isCore && !deferred) { this.logger.debug(`Deferring non-core face ${id} for later processing`); - await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); - return JobStatus.SKIPPED; + await this.jobRepository.queue({ name: JobName.FacialRecognition, data: { id, deferred: true } }); + return JobStatus.Skipped; } let personId = matches.find((match) => match.personId)?.personId; @@ -526,7 +526,7 @@ export class PersonService extends BaseService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); - await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); + await this.jobRepository.queue({ name: JobName.GeneratePersonThumbnail, data: { id: newPerson.id } }); personId = newPerson.id; } @@ -535,19 +535,19 @@ export class PersonService extends BaseService { await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); } - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.MIGRATE_PERSON, queue: QueueName.MIGRATION }) - async handlePersonMigration({ id }: JobOf): Promise { + @OnJob({ name: JobName.MigratePerson, queue: QueueName.Migration }) + async handlePersonMigration({ id }: JobOf): Promise { const person = await this.personRepository.getById(id); if (!person) { - return JobStatus.FAILED; + return JobStatus.Failed; } - await this.storageCore.movePersonFile(person, PersonPathType.FACE); + await this.storageCore.movePersonFile(person, PersonPathType.Face); - return JobStatus.SUCCESS; + return JobStatus.Success; } async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise { @@ -556,7 +556,7 @@ export class PersonService extends BaseService { throw new BadRequestException('Cannot merge a person into themselves'); } - await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); let primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; @@ -564,7 +564,7 @@ export class PersonService extends BaseService { const allowedIds = await this.checkAccess({ auth, - permission: Permission.PERSON_MERGE, + permission: Permission.PersonMerge, ids: mergeIds, }); @@ -623,8 +623,8 @@ export class PersonService extends BaseService { // TODO return a asset face response async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise { await Promise.all([ - this.requireAccess({ auth, permission: Permission.ASSET_READ, ids: [dto.assetId] }), - this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [dto.personId] }), + this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }), + this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }), ]); await this.personRepository.createAssetFace({ @@ -636,12 +636,12 @@ export class PersonService extends BaseService { boundingBoxX2: dto.x + dto.width, boundingBoxY1: dto.y, boundingBoxY2: dto.y + dto.height, - sourceType: SourceType.MANUAL, + sourceType: SourceType.Manual, }); } async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise { - await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.FaceDelete, ids: [id] }); return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id); } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index a10c01e8d3..1c75c4a434 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -46,7 +46,7 @@ export class SearchService extends BaseService { } async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise { - if (dto.visibility === AssetVisibility.LOCKED) { + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -65,7 +65,7 @@ export class SearchService extends BaseService { ...dto, checksum, userIds, - orderDirection: dto.order ?? AssetOrder.DESC, + orderDirection: dto.order ?? AssetOrder.Desc, }, ); @@ -82,7 +82,7 @@ export class SearchService extends BaseService { } async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise { - if (dto.visibility === AssetVisibility.LOCKED) { + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } @@ -92,7 +92,7 @@ export class SearchService extends BaseService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - if (dto.visibility === AssetVisibility.LOCKED) { + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 05ebda6a94..0ddf3d69b1 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -256,7 +256,7 @@ describe(ServerService.name, () => { const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; await sut.setLicense(license); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.License, expect.any(Object)); }); it('should not save license if invalid', async () => { diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 5ad93b40ef..dae484cce8 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -27,7 +27,7 @@ export class ServerService extends BaseService { async onBootstrap(): Promise { const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { - await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + await this.systemMetadataRepository.set(SystemMetadataKey.AdminOnboarding, { isOnboarded: true, }); } @@ -38,7 +38,7 @@ export class ServerService extends BaseService { const version = `v${serverVersion.toString()}`; const { buildMetadata } = this.configRepository.getEnv(); const buildVersions = await this.serverInfoRepository.getBuildVersions(); - const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); + const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.License); return { version, @@ -60,7 +60,7 @@ export class ServerService extends BaseService { } async getStorage(): Promise { - const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); + const libraryBase = StorageCore.getBaseFolder(StorageFolder.Library); const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase); const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2); @@ -111,7 +111,7 @@ export class ServerService extends BaseService { async getSystemConfig(): Promise { const config = await this.getConfig({ withCache: false }); const isInitialized = await this.userRepository.hasAdmin(); - const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); + const onboarding = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding); return { loginPageMessage: config.server.loginPageMessage, @@ -163,11 +163,11 @@ export class ServerService extends BaseService { } async deleteLicense(): Promise { - await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE); + await this.systemMetadataRepository.delete(SystemMetadataKey.License); } async getLicense(): Promise { - const license = await this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); + const license = await this.systemMetadataRepository.get(SystemMetadataKey.License); if (!license) { throw new NotFoundException(); } @@ -186,7 +186,7 @@ export class ServerService extends BaseService { const licenseData = { ...dto, activatedAt: new Date() }; - await this.systemMetadataRepository.set(SystemMetadataKey.LICENSE, licenseData); + await this.systemMetadataRepository.set(SystemMetadataKey.License, licenseData); return licenseData; } diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 7ac338da80..3cbad28389 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -19,7 +19,7 @@ describe('SessionService', () => { describe('handleCleanup', () => { it('should clean sessions', async () => { mocks.session.cleanup.mockResolvedValue([]); - await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); + await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.Success); }); }); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 198e380c53..b7bb5cb6fa 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -14,7 +14,7 @@ import { BaseService } from 'src/services/base.service'; @Injectable() export class SessionService extends BaseService { - @OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.CleanOldSessionTokens, queue: QueueName.BackgroundTask }) async handleCleanup(): Promise { const sessions = await this.sessionRepository.cleanup(); for (const session of sessions) { @@ -23,7 +23,7 @@ export class SessionService extends BaseService { this.logger.log(`Deleted ${sessions.length} expired session tokens`); - return JobStatus.SUCCESS; + return JobStatus.Success; } async create(auth: AuthDto, dto: SessionCreateDto): Promise { @@ -51,7 +51,7 @@ export class SessionService extends BaseService { } async update(auth: AuthDto, id: string, dto: SessionUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.SESSION_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.SessionUpdate, ids: [id] }); if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { throw new BadRequestException('No fields to update'); @@ -65,12 +65,12 @@ export class SessionService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.AuthDeviceDelete, ids: [id] }); await this.sessionRepository.delete(id); } async lock(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.SESSION_LOCK, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.SessionLock, ids: [id] }); await this.sessionRepository.update(id, { pinExpiresAt: null }); } diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index b3b4c4b1cf..8e09580d55 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -95,26 +95,26 @@ describe(SharedLinkService.name, () => { describe('create', () => { it('should not allow an album shared link without an albumId', async () => { - await expect(sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [] })).rejects.toBeInstanceOf( + await expect(sut.create(authStub.admin, { type: SharedLinkType.Album, assetIds: [] })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should not allow non-owners to create album shared links', async () => { await expect( - sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }), + sut.create(authStub.admin, { type: SharedLinkType.Album, assetIds: [], albumId: 'album-1' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should not allow individual shared links with no assets', async () => { await expect( - sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: [] }), + sut.create(authStub.admin, { type: SharedLinkType.Individual, assetIds: [] }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should require asset ownership to make an individual shared link', async () => { await expect( - sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }), + sut.create(authStub.admin, { type: SharedLinkType.Individual, assetIds: ['asset-1'] }), ).rejects.toBeInstanceOf(BadRequestException); }); @@ -122,14 +122,14 @@ describe(SharedLinkService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); - await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id }); + await sut.create(authStub.admin, { type: SharedLinkType.Album, albumId: albumStub.oneAsset.id }); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([albumStub.oneAsset.id]), ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, userId: authStub.admin.user.id, albumId: albumStub.oneAsset.id, allowDownload: true, @@ -146,7 +146,7 @@ describe(SharedLinkService.name, () => { mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { - type: SharedLinkType.INDIVIDUAL, + type: SharedLinkType.Individual, assetIds: [assetStub.image.id], showMetadata: true, allowDownload: true, @@ -159,7 +159,7 @@ describe(SharedLinkService.name, () => { false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ - type: SharedLinkType.INDIVIDUAL, + type: SharedLinkType.Individual, userId: authStub.admin.user.id, albumId: null, allowDownload: true, @@ -177,7 +177,7 @@ describe(SharedLinkService.name, () => { mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { - type: SharedLinkType.INDIVIDUAL, + type: SharedLinkType.Individual, assetIds: [assetStub.image.id], showMetadata: false, allowDownload: true, @@ -190,7 +190,7 @@ describe(SharedLinkService.name, () => { false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ - type: SharedLinkType.INDIVIDUAL, + type: SharedLinkType.Individual, userId: authStub.admin.user.id, albumId: null, allowDownload: false, diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index c70b31a3a1..9f8e238c43 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -45,20 +45,20 @@ export class SharedLinkService extends BaseService { async create(auth: AuthDto, dto: SharedLinkCreateDto): Promise { switch (dto.type) { - case SharedLinkType.ALBUM: { + case SharedLinkType.Album: { if (!dto.albumId) { throw new BadRequestException('Invalid albumId'); } - await this.requireAccess({ auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.AlbumShare, ids: [dto.albumId] }); break; } - case SharedLinkType.INDIVIDUAL: { + case SharedLinkType.Individual: { if (!dto.assetIds || dto.assetIds.length === 0) { throw new BadRequestException('Invalid assetIds'); } - await this.requireAccess({ auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.AssetShare, ids: dto.assetIds }); break; } @@ -113,7 +113,7 @@ export class SharedLinkService extends BaseService { async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { const sharedLink = await this.findOrFail(auth.user.id, id); - if (sharedLink.type !== SharedLinkType.INDIVIDUAL) { + if (sharedLink.type !== SharedLinkType.Individual) { throw new BadRequestException('Invalid shared link type'); } @@ -121,7 +121,7 @@ export class SharedLinkService extends BaseService { const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId)); const allowedAssetIds = await this.checkAccess({ auth, - permission: Permission.ASSET_SHARE, + permission: Permission.AssetShare, ids: notPresentAssetIds, }); @@ -153,7 +153,7 @@ export class SharedLinkService extends BaseService { async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise { const sharedLink = await this.findOrFail(auth.user.id, id); - if (sharedLink.type !== SharedLinkType.INDIVIDUAL) { + if (sharedLink.type !== SharedLinkType.Individual) { throw new BadRequestException('Invalid shared link type'); } diff --git a/server/src/services/smart-info.service.spec.ts b/server/src/services/smart-info.service.spec.ts index a6529fa623..edd9f4663a 100644 --- a/server/src/services/smart-info.service.spec.ts +++ b/server/src/services/smart-info.service.spec.ts @@ -14,7 +14,7 @@ describe(SmartInfoService.name, () => { ({ sut, mocks } = newTestService(SmartInfoService)); mocks.asset.getByIds.mockResolvedValue([assetStub.image]); - mocks.config.getWorker.mockReturnValue(ImmichWorker.MICROSERVICES); + mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); }); it('should work', () => { @@ -160,7 +160,7 @@ describe(SmartInfoService.name, () => { await sut.handleQueueEncodeClip({ force: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + { name: JobName.SmartSearch, data: { id: assetStub.image.id } }, ]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(false); expect(mocks.database.setDimensionSize).not.toHaveBeenCalled(); @@ -172,7 +172,7 @@ describe(SmartInfoService.name, () => { await sut.handleQueueEncodeClip({ force: true }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }, + { name: JobName.SmartSearch, data: { id: assetStub.image.id } }, ]); expect(mocks.assetJob.streamForEncodeClip).toHaveBeenCalledWith(true); expect(mocks.database.setDimensionSize).toHaveBeenCalledExactlyOnceWith(512); @@ -183,7 +183,7 @@ describe(SmartInfoService.name, () => { it('should do nothing if machine learning is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.machineLearningDisabled); - expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.Skipped); expect(mocks.asset.getByIds).not.toHaveBeenCalled(); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); @@ -192,7 +192,7 @@ describe(SmartInfoService.name, () => { it('should skip assets without a resize path', async () => { mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.noResizePath, files: [] }); - expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED); + expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.Failed); expect(mocks.search.upsert).not.toHaveBeenCalled(); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); @@ -202,7 +202,7 @@ describe(SmartInfoService.name, () => { mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]'); mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( ['http://immich-machine-learning:3003'], @@ -218,7 +218,7 @@ describe(SmartInfoService.name, () => { files: [assetStub.image.files[1]], }); - expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED); + expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.Skipped); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled(); @@ -227,7 +227,7 @@ describe(SmartInfoService.name, () => { it('should fail if asset could not be found', async () => { mocks.assetJob.getForClipEncoding.mockResolvedValue(void 0); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.FAILED); + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Failed); expect(mocks.machineLearning.encodeImage).not.toHaveBeenCalled(); expect(mocks.search.upsert).not.toHaveBeenCalled(); @@ -238,7 +238,7 @@ describe(SmartInfoService.name, () => { mocks.database.isBusy.mockReturnValue(true); mocks.assetJob.getForClipEncoding.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); - expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS); + expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index d6e30c6d86..5ce47593f4 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -10,12 +10,12 @@ import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SmartInfoService extends BaseService { - @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.MICROSERVICES] }) + @OnEvent({ name: 'ConfigInit', workers: [ImmichWorker.Microservices] }) async onConfigInit({ newConfig }: ArgOf<'ConfigInit'>) { await this.init(newConfig); } - @OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.MICROSERVICES], server: true }) + @OnEvent({ name: 'ConfigUpdate', workers: [ImmichWorker.Microservices], server: true }) async onConfigUpdate({ oldConfig, newConfig }: ArgOf<'ConfigUpdate'>) { await this.init(newConfig, oldConfig); } @@ -64,11 +64,11 @@ export class SmartInfoService extends BaseService { }); } - @OnJob({ name: JobName.QUEUE_SMART_SEARCH, queue: QueueName.SMART_SEARCH }) - async handleQueueEncodeClip({ force }: JobOf): Promise { + @OnJob({ name: JobName.QueueSmartSearch, queue: QueueName.SmartSearch }) + async handleQueueEncodeClip({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } if (force) { @@ -80,7 +80,7 @@ export class SmartInfoService extends BaseService { let queue: JobItem[] = []; const assets = this.assetJobRepository.streamForEncodeClip(force); for await (const asset of assets) { - queue.push({ name: JobName.SMART_SEARCH, data: { id: asset.id } }); + queue.push({ name: JobName.SmartSearch, data: { id: asset.id } }); if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(queue); queue = []; @@ -89,23 +89,23 @@ export class SmartInfoService extends BaseService { await this.jobRepository.queueAll(queue); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.SMART_SEARCH, queue: QueueName.SMART_SEARCH }) - async handleEncodeClip({ id }: JobOf): Promise { + @OnJob({ name: JobName.SmartSearch, queue: QueueName.SmartSearch }) + async handleEncodeClip({ id }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: true }); if (!isSmartSearchEnabled(machineLearning)) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const asset = await this.assetJobRepository.getForClipEncoding(id); if (!asset || asset.files.length !== 1) { - return JobStatus.FAILED; + return JobStatus.Failed; } - if (asset.visibility === AssetVisibility.HIDDEN) { - return JobStatus.SKIPPED; + if (asset.visibility === AssetVisibility.Hidden) { + return JobStatus.Skipped; } const embedding = await this.machineLearningRepository.encodeImage( @@ -122,11 +122,11 @@ export class SmartInfoService extends BaseService { const newConfig = await this.getConfig({ withCache: true }); if (machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName) { // Skip the job if the the model has changed since the embedding was generated. - return JobStatus.SKIPPED; + return JobStatus.Skipped; } await this.searchRepository.upsert(asset.id, embedding); - return JobStatus.SUCCESS; + return JobStatus.Success; } } diff --git a/server/src/services/stack.service.ts b/server/src/services/stack.service.ts index b2ac47274f..18600abd12 100644 --- a/server/src/services/stack.service.ts +++ b/server/src/services/stack.service.ts @@ -17,7 +17,7 @@ export class StackService extends BaseService { } async create(auth: AuthDto, dto: StackCreateDto): Promise { - await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }); + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); const stack = await this.stackRepository.create({ ownerId: auth.user.id }, dto.assetIds); @@ -27,13 +27,13 @@ export class StackService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.STACK_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.StackRead, ids: [id] }); const stack = await this.findOrFail(id); return mapStack(stack, { auth }); } async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.STACK_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.StackUpdate, ids: [id] }); const stack = await this.findOrFail(id); if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) { throw new BadRequestException('Primary asset must be in the stack'); @@ -47,13 +47,13 @@ export class StackService extends BaseService { } async delete(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.StackDelete, ids: [id] }); await this.stackRepository.delete(id); await this.eventRepository.emit('StackDelete', { stackId: id, userId: auth.user.id }); } async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.STACK_DELETE, ids: dto.ids }); + await this.requireAccess({ auth, permission: Permission.StackDelete, ids: dto.ids }); await this.stackRepository.deleteAll(dto.ids); await this.eventRepository.emit('StackDeleteAll', { stackIds: dto.ids, userId: auth.user.id }); } diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 9c4fe02f3e..2751651dbf 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -96,7 +96,7 @@ describe(StorageTemplateService.name, () => { it('should skip when storage template is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ storageTemplate: { enabled: false } }); - await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.SKIPPED); + await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Skipped); expect(mocks.asset.getByIds).not.toHaveBeenCalled(); expect(mocks.storage.checkFileExists).not.toHaveBeenCalled(); @@ -119,7 +119,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValueOnce({ id: '123', entityId: stillAsset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: stillAsset.originalPath, newPath: newStillPicturePath, }); @@ -127,12 +127,12 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValueOnce({ id: '124', entityId: motionAsset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: motionAsset.originalPath, newPath: newMotionPicturePath, }); - await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2); expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath }); @@ -152,13 +152,13 @@ describe(StorageTemplateService.name, () => { mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); mocks.album.getByAssetId.mockResolvedValueOnce([album]); - expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, oldPath: asset.originalPath, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, }); }); @@ -172,14 +172,14 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); - expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, oldPath: asset.originalPath, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, }); }); @@ -206,14 +206,14 @@ describe(StorageTemplateService.name, () => { }, ]); - expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`, oldPath: asset.originalPath, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, }); }); @@ -229,14 +229,14 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(user); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset); - expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS); + expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`, oldPath: asset.originalPath, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, }); }); @@ -251,7 +251,7 @@ describe(StorageTemplateService.name, () => { mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: asset.originalPath, newPath: previousFailedNewPath, }); @@ -259,12 +259,12 @@ describe(StorageTemplateService.name, () => { mocks.move.update.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: asset.originalPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); @@ -293,7 +293,7 @@ describe(StorageTemplateService.name, () => { mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: asset.originalPath, newPath: previousFailedNewPath, }); @@ -301,12 +301,12 @@ describe(StorageTemplateService.name, () => { mocks.move.update.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: previousFailedNewPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); @@ -328,19 +328,19 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: testAsset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: testAsset.originalPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1); expect(mocks.storage.stat).toHaveBeenCalledWith(newPath); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: testAsset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: testAsset.originalPath, newPath, }); @@ -370,7 +370,7 @@ describe(StorageTemplateService.name, () => { mocks.move.getByEntity.mockResolvedValue({ id: '123', entityId: testAsset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: testAsset.originalPath, newPath: previousFailedNewPath, }); @@ -378,12 +378,12 @@ describe(StorageTemplateService.name, () => { mocks.move.update.mockResolvedValue({ id: '123', entityId: testAsset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: previousFailedNewPath, newPath, }); - await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success); expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id); expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(3); @@ -417,7 +417,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath, newPath, }); @@ -472,7 +472,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: assetStub.image.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: assetStub.image.originalPath, newPath, }); @@ -492,7 +492,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: asset.originalPath, newPath: `upload/library/${user.storageLabel}/2023/2023-02-23/${asset.originalFileName}`, }); @@ -520,7 +520,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath, newPath, }); @@ -559,7 +559,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: asset.originalPath, newPath: `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, }); @@ -592,7 +592,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: 'move-123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: asset.originalPath, newPath: '', }); @@ -622,7 +622,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`, }); @@ -648,7 +648,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`, }); @@ -674,7 +674,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`, }); @@ -700,7 +700,7 @@ describe(StorageTemplateService.name, () => { mocks.move.create.mockResolvedValue({ id: '123', entityId: asset.id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`, newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`, }); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index a286d518d6..6086d62809 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -97,7 +97,7 @@ export class StorageTemplateService extends BaseService { asset: { fileCreatedAt: new Date(), originalPath: '/upload/test/IMG_123.jpg', - type: AssetType.IMAGE, + type: AssetType.Image, id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', } as StorageAsset, filename: 'IMG_123', @@ -118,20 +118,20 @@ export class StorageTemplateService extends BaseService { @OnEvent({ name: 'AssetMetadataExtracted' }) async onAssetMetadataExtracted({ source, assetId }: ArgOf<'AssetMetadataExtracted'>) { - await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { source, id: assetId } }); + await this.jobRepository.queue({ name: JobName.StorageTemplateMigrationSingle, data: { source, id: assetId } }); } - @OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION }) - async handleMigrationSingle({ id }: JobOf): Promise { + @OnJob({ name: JobName.StorageTemplateMigrationSingle, queue: QueueName.StorageTemplateMigration }) + async handleMigrationSingle({ id }: JobOf): Promise { const config = await this.getConfig({ withCache: true }); const storageTemplateEnabled = config.storageTemplate.enabled; if (!storageTemplateEnabled) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } const asset = await this.assetJobRepository.getForStorageTemplateJob(id); if (!asset) { - return JobStatus.FAILED; + return JobStatus.Failed; } const user = await this.userRepository.get(asset.ownerId, {}); @@ -143,22 +143,22 @@ export class StorageTemplateService extends BaseService { if (asset.livePhotoVideoId) { const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId); if (!livePhotoVideo) { - return JobStatus.FAILED; + return JobStatus.Failed; } const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); } - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION, queue: QueueName.STORAGE_TEMPLATE_MIGRATION }) + @OnJob({ name: JobName.StorageTemplateMigration, queue: QueueName.StorageTemplateMigration }) async handleMigration(): Promise { this.logger.log('Starting storage template migration'); const { storageTemplate } = await this.getConfig({ withCache: true }); const { enabled } = storageTemplate; if (!enabled) { this.logger.log('Storage template migration disabled, skipping'); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } await this.moveRepository.cleanMoveHistory(); @@ -174,12 +174,12 @@ export class StorageTemplateService extends BaseService { } this.logger.debug('Cleaning up empty directories...'); - const libraryFolder = StorageCore.getBaseFolder(StorageFolder.LIBRARY); + const libraryFolder = StorageCore.getBaseFolder(StorageFolder.Library); await this.storageRepository.removeEmptyDirs(libraryFolder); this.logger.log('Finished storage template migration'); - return JobStatus.SUCCESS; + return JobStatus.Success; } @OnEvent({ name: 'AssetDelete' }) @@ -208,7 +208,7 @@ export class StorageTemplateService extends BaseService { try { await this.storageCore.moveFile({ entityId: id, - pathType: AssetPathType.ORIGINAL, + pathType: AssetPathType.Original, oldPath, newPath, assetInfo: { sizeInBytes: fileSizeInByte, checksum }, @@ -216,7 +216,7 @@ export class StorageTemplateService extends BaseService { if (sidecarPath) { await this.storageCore.moveFile({ entityId: id, - pathType: AssetPathType.SIDECAR, + pathType: AssetPathType.Sidecar, oldPath: sidecarPath, newPath: `${newPath}.xmp`, }); @@ -357,8 +357,8 @@ export class StorageTemplateService extends BaseService { const substitutions: Record = { filename, ext: extension, - filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', - filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', + filetype: asset.type == AssetType.Image ? 'IMG' : 'VID', + filetypefull: asset.type == AssetType.Image ? 'IMAGE' : 'VIDEO', assetId: asset.id, assetIdShort: asset.id.slice(-12), //just throw into the root if it doesn't belong to an album diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 2d28489fae..0e051f2642 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -22,7 +22,7 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SystemFlags, { mountChecks: { backups: true, 'encoded-video': true, @@ -60,7 +60,7 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.SystemFlags, { mountChecks: { backups: true, 'encoded-video': true, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index e9ca10f08a..d8804665da 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -17,7 +17,7 @@ export class StorageService extends BaseService { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { const flags = - (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || + (await this.systemMetadataRepository.get(SystemMetadataKey.SystemFlags)) || ({ mountChecks: {} } as SystemFlags); if (!flags.mountChecks) { @@ -46,7 +46,7 @@ export class StorageService extends BaseService { } if (updated) { - await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags); + await this.systemMetadataRepository.set(SystemMetadataKey.SystemFlags, flags); this.logger.log('Successfully enabled system mount folders checks'); } @@ -62,8 +62,8 @@ export class StorageService extends BaseService { }); } - @OnJob({ name: JobName.DELETE_FILES, queue: QueueName.BACKGROUND_TASK }) - async handleDeleteFiles(job: JobOf): Promise { + @OnJob({ name: JobName.DeleteFiles, queue: QueueName.BackgroundTask }) + async handleDeleteFiles(job: JobOf): Promise { const { files } = job; // TODO: one job per file @@ -79,7 +79,7 @@ export class StorageService extends BaseService { } } - return JobStatus.SUCCESS; + return JobStatus.Success; } private async verifyReadAccess(folder: StorageFolder) { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 9779498d70..4463ab0d76 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -640,7 +640,7 @@ export class SyncService extends BaseService { async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise { // mobile implementation is faster if this is a single id const userId = dto.userId || auth.user.id; - await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [userId] }); + await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: [userId] }); const assets = await this.assetRepository.getAllForUserFullSync({ ownerId: userId, updatedUntil: dto.updatedUntil, @@ -664,7 +664,7 @@ export class SyncService extends BaseService { return FULL_SYNC; } - await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: dto.userIds }); + await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: dto.userIds }); const limit = 10_000; const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds }); @@ -676,8 +676,8 @@ export class SyncService extends BaseService { const deleted = await this.auditRepository.getAfter(dto.updatedAfter, { userIds, - entityType: EntityType.ASSET, - action: DatabaseAction.DELETE, + entityType: EntityType.Asset, + action: DatabaseAction.Delete, }); const result = { @@ -686,7 +686,7 @@ export class SyncService extends BaseService { // do not return archived assets for partner users .filter( (a) => - a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && a.visibility === AssetVisibility.TIMELINE), + a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && a.visibility === AssetVisibility.Timeline), ) .map((a) => mapAsset(a, { diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 582c50ed8a..20127bab15 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -9,7 +9,7 @@ import { OAuthTokenEndpointAuthMethod, QueueName, ToneMapping, - TranscodeHWAccel, + TranscodeHardwareAcceleration, TranscodePolicy, VideoCodec, VideoContainer, @@ -28,17 +28,17 @@ const partialConfig = { const updatedConfig = Object.freeze({ job: { - [QueueName.BACKGROUND_TASK]: { concurrency: 5 }, - [QueueName.SMART_SEARCH]: { concurrency: 2 }, - [QueueName.METADATA_EXTRACTION]: { concurrency: 5 }, - [QueueName.FACE_DETECTION]: { concurrency: 2 }, - [QueueName.SEARCH]: { concurrency: 5 }, - [QueueName.SIDECAR]: { concurrency: 5 }, - [QueueName.LIBRARY]: { concurrency: 5 }, - [QueueName.MIGRATION]: { concurrency: 5 }, - [QueueName.THUMBNAIL_GENERATION]: { concurrency: 3 }, - [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, - [QueueName.NOTIFICATION]: { concurrency: 5 }, + [QueueName.BackgroundTask]: { concurrency: 5 }, + [QueueName.SmartSearch]: { concurrency: 2 }, + [QueueName.MetadataExtraction]: { concurrency: 5 }, + [QueueName.FaceDetection]: { concurrency: 2 }, + [QueueName.Search]: { concurrency: 5 }, + [QueueName.Sidecar]: { concurrency: 5 }, + [QueueName.Library]: { concurrency: 5 }, + [QueueName.Migration]: { concurrency: 5 }, + [QueueName.ThumbnailGeneration]: { concurrency: 3 }, + [QueueName.VideoConversion]: { concurrency: 1 }, + [QueueName.Notification]: { concurrency: 5 }, }, backup: { database: { @@ -51,28 +51,28 @@ const updatedConfig = Object.freeze({ crf: 30, threads: 0, preset: 'ultrafast', - targetAudioCodec: AudioCodec.AAC, - acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS, AudioCodec.PCMS16LE], + targetAudioCodec: AudioCodec.Aac, + acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus, AudioCodec.PcmS16le], targetResolution: '720', targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], - acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], + acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm], maxBitrate: '0', bframes: -1, refs: 0, gopSize: 0, temporalAQ: false, - cqMode: CQMode.AUTO, + cqMode: CQMode.Auto, twoPass: false, preferredHwDevice: 'auto', - transcode: TranscodePolicy.REQUIRED, - accel: TranscodeHWAccel.DISABLED, + transcode: TranscodePolicy.Required, + accel: TranscodeHardwareAcceleration.Disabled, accelDecode: false, - tonemap: ToneMapping.HABLE, + tonemap: ToneMapping.Hable, }, logging: { enabled: true, - level: LogLevel.LOG, + level: LogLevel.Log, }, metadata: { faces: { @@ -128,7 +128,7 @@ const updatedConfig = Object.freeze({ scope: 'openid email profile', signingAlgorithm: 'RS256', profileSigningAlgorithm: 'none', - tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.CLIENT_SECRET_POST, + tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost, timeout: 30_000, storageLabelClaim: 'preferred_username', storageQuotaClaim: 'immich_quota', @@ -150,15 +150,15 @@ const updatedConfig = Object.freeze({ image: { thumbnail: { size: 250, - format: ImageFormat.WEBP, + format: ImageFormat.Webp, quality: 80, }, preview: { size: 1440, - format: ImageFormat.JPEG, + format: ImageFormat.Jpeg, quality: 80, }, - fullsize: { enabled: false, format: ImageFormat.JPEG, quality: 80 }, + fullsize: { enabled: false, format: ImageFormat.Jpeg, quality: 80 }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/services/system-metadata.service.spec.ts b/server/src/services/system-metadata.service.spec.ts index a8d6c0cdcc..f5bdcde7b4 100644 --- a/server/src/services/system-metadata.service.spec.ts +++ b/server/src/services/system-metadata.service.spec.ts @@ -30,12 +30,12 @@ describe(SystemMetadataService.name, () => { describe('updateAdminOnboarding', () => { it('should update isOnboarded to true', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined(); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.AdminOnboarding, { isOnboarded: true }); }); it('should update isOnboarded to false', async () => { await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined(); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false }); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.AdminOnboarding, { isOnboarded: false }); }); }); diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts index 750e6b1d0b..30af715379 100644 --- a/server/src/services/system-metadata.service.ts +++ b/server/src/services/system-metadata.service.ts @@ -11,23 +11,23 @@ import { BaseService } from 'src/services/base.service'; @Injectable() export class SystemMetadataService extends BaseService { async getAdminOnboarding(): Promise { - const value = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.AdminOnboarding); return { isOnboarded: false, ...value }; } async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise { - await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { + await this.systemMetadataRepository.set(SystemMetadataKey.AdminOnboarding, { isOnboarded: dto.isOnboarded, }); } async getReverseGeocodingState(): Promise { - const value = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.ReverseGeocodingState); return { lastUpdate: null, lastImportFileName: null, ...value }; } async getVersionCheckState(): Promise { - const value = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); + const value = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); return { checkedAt: null, releaseVersion: null, ...value }; } } diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 70507ab433..6699c61970 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -278,7 +278,7 @@ describe(TagService.name, () => { it('should delete empty tags', async () => { mocks.tag.deleteEmptyTags.mockResolvedValue(); - await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.SUCCESS); + await expect(sut.handleTagCleanup()).resolves.toBe(JobStatus.Success); expect(mocks.tag.deleteEmptyTags).toHaveBeenCalled(); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index e975fc3980..2fae4b55d0 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -26,7 +26,7 @@ export class TagService extends BaseService { } async get(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TagRead, ids: [id] }); const tag = await this.findOrFail(id); return mapTag(tag); } @@ -34,7 +34,7 @@ export class TagService extends BaseService { async create(auth: AuthDto, dto: TagCreateDto) { let parent; if (dto.parentId) { - await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.parentId] }); + await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.parentId] }); parent = await this.tagRepository.get(dto.parentId); if (!parent) { throw new BadRequestException('Tag not found'); @@ -55,7 +55,7 @@ export class TagService extends BaseService { } async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { - await this.requireAccess({ auth, permission: Permission.TAG_UPDATE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TagUpdate, ids: [id] }); const { color } = dto; const tag = await this.tagRepository.update(id, { color }); @@ -68,7 +68,7 @@ export class TagService extends BaseService { } async remove(auth: AuthDto, id: string): Promise { - await this.requireAccess({ auth, permission: Permission.TAG_DELETE, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TagDelete, ids: [id] }); // TODO sync tag changes for affected assets @@ -77,8 +77,8 @@ export class TagService extends BaseService { async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise { const [tagIds, assetIds] = await Promise.all([ - this.checkAccess({ auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }), - this.checkAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }), + this.checkAccess({ auth, permission: Permission.TagAsset, ids: dto.tagIds }), + this.checkAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }), ]); const items: Insertable[] = []; @@ -97,7 +97,7 @@ export class TagService extends BaseService { } async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TagAsset, ids: [id] }); const results = await addAssets( auth, @@ -115,12 +115,12 @@ export class TagService extends BaseService { } async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { - await this.requireAccess({ auth, permission: Permission.TAG_ASSET, ids: [id] }); + await this.requireAccess({ auth, permission: Permission.TagAsset, ids: [id] }); const results = await removeAssets( auth, { access: this.accessRepository, bulk: this.tagRepository }, - { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE }, + { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TagDelete }, ); for (const { id: assetId, success } of results) { @@ -132,10 +132,10 @@ export class TagService extends BaseService { return results; } - @OnJob({ name: JobName.TAG_CLEANUP, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.TagCleanup, queue: QueueName.BackgroundTask }) async handleTagCleanup() { await this.tagRepository.deleteEmptyTags(); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async findOrFail(id: string) { diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 1669b1eac7..11df30a7d4 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -49,7 +49,7 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, + visibility: AssetVisibility.Archive, userId: authStub.admin.user.id, }), ).resolves.toEqual(json); @@ -57,7 +57,7 @@ describe(TimelineService.name, () => { 'bucket', expect.objectContaining({ timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, + visibility: AssetVisibility.Archive, userIds: [authStub.admin.user.id], }), ); @@ -71,14 +71,14 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, userId: authStub.admin.user.id, withPartners: true, }), ).resolves.toEqual(json); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { timeBucket: 'bucket', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, withPartners: true, userIds: [authStub.admin.user.id], }); @@ -126,7 +126,7 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', - visibility: AssetVisibility.ARCHIVE, + visibility: AssetVisibility.Archive, withPartners: true, userId: authStub.admin.user.id, }), diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index abd536a97e..d8cac3a205 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -45,29 +45,29 @@ export class TimelineService extends BaseService { } private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) { - if (dto.visibility === AssetVisibility.LOCKED) { + if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); } if (dto.albumId) { - await this.requireAccess({ auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] }); + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [dto.albumId] }); } else { dto.userId = dto.userId || auth.user.id; } if (dto.userId) { - await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] }); - if (dto.visibility === AssetVisibility.ARCHIVE) { - await this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] }); + await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: [dto.userId] }); + if (dto.visibility === AssetVisibility.Archive) { + await this.requireAccess({ auth, permission: Permission.ArchiveRead, ids: [dto.userId] }); } } if (dto.tagId) { - await this.requireAccess({ auth, permission: Permission.TAG_READ, ids: [dto.tagId] }); + await this.requireAccess({ auth, permission: Permission.TagRead, ids: [dto.tagId] }); } if (dto.withPartners) { - const requestedArchived = dto.visibility === AssetVisibility.ARCHIVE || dto.visibility === undefined; + const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false; const requestedTrash = dto.isTrashed === true; diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index b3bee90815..f742ce8757 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -77,24 +77,24 @@ describe(TrashService.name, () => { mocks.trash.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueTrashEmpty, data: {} }); }); }); describe('onAssetsDelete', () => { it('should queue the empty trash job', async () => { await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueTrashEmpty, data: {} }); }); }); describe('handleQueueEmptyTrash', () => { it('should queue asset delete jobs', async () => { mocks.trash.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); - await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS); + await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.Success); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id: 'asset-1', deleteOnDisk: true }, }, ]); diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index a7447ab890..858feacd39 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -15,7 +15,7 @@ export class TrashService extends BaseService { return { count: 0 }; } - await this.requireAccess({ auth, permission: Permission.ASSET_DELETE, ids }); + await this.requireAccess({ auth, permission: Permission.AssetDelete, ids }); await this.trashRepository.restoreAll(ids); await this.eventRepository.emit('AssetRestoreAll', { assetIds: ids, userId: auth.user.id }); @@ -35,17 +35,17 @@ export class TrashService extends BaseService { async empty(auth: AuthDto): Promise { const count = await this.trashRepository.empty(auth.user.id); if (count > 0) { - await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + await this.jobRepository.queue({ name: JobName.QueueTrashEmpty, data: {} }); } return { count }; } @OnEvent({ name: 'AssetDeleteAll' }) async onAssetsDelete() { - await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); + await this.jobRepository.queue({ name: JobName.QueueTrashEmpty, data: {} }); } - @OnJob({ name: JobName.QUEUE_TRASH_EMPTY, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.QueueTrashEmpty, queue: QueueName.BackgroundTask }) async handleQueueEmptyTrash() { const assets = this.trashRepository.getDeletedIds(); @@ -67,14 +67,14 @@ export class TrashService extends BaseService { this.logger.log(`Queued ${count} asset(s) for deletion from the trash`); - return JobStatus.SUCCESS; + return JobStatus.Success; } private async handleBatch(ids: string[]) { this.logger.debug(`Queueing ${ids.length} asset(s) for deletion from the trash`); await this.jobRepository.queueAll( ids.map((assetId) => ({ - name: JobName.ASSET_DELETION, + name: JobName.AssetDeletion, data: { id: assetId, deleteOnDisk: true, diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 85cbb8238a..8854ade8e3 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -140,7 +140,7 @@ describe(UserAdminService.name, () => { await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUserAdmin(userStub.user1)); expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { - status: UserStatus.DELETED, + status: UserStatus.Deleted, deletedAt: expect.any(Date), }); }); @@ -154,11 +154,11 @@ describe(UserAdminService.name, () => { ); expect(mocks.user.update).toHaveBeenCalledWith(userStub.user1.id, { - status: UserStatus.REMOVING, + status: UserStatus.Removing, deletedAt: expect.any(Date), }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.USER_DELETION, + name: JobName.UserDeletion, data: { id: userStub.user1.id, force: true }, }); }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 180471bb44..91e21b6bb5 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -100,11 +100,11 @@ export class UserAdminService extends BaseService { await this.albumRepository.softDeleteAll(id); - const status = force ? UserStatus.REMOVING : UserStatus.DELETED; + const status = force ? UserStatus.Removing : UserStatus.Deleted; const user = await this.userRepository.update(id, { status, deletedAt: new Date() }); if (force) { - await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } }); + await this.jobRepository.queue({ name: JobName.UserDeletion, data: { id: user.id, force } }); } return mapUserAdmin(user); @@ -134,7 +134,7 @@ export class UserAdminService extends BaseService { const newPreferences = mergePreferences(getPreferences(metadata), dto); await this.userRepository.upsertMetadata(id, { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: getPreferencesPartial(newPreferences), }); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 4ee92fc3d3..6ae6bc85b6 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -122,7 +122,7 @@ describe(UserService.name, () => { await sut.createProfileImage(authStub.admin, file); - expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DeleteFiles, data: { files } }]]); }); it('should not delete the profile image if it has not been set', async () => { @@ -156,7 +156,7 @@ describe(UserService.name, () => { await sut.deleteProfileImage(authStub.admin); - expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DELETE_FILES, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DeleteFiles, data: { files } }]]); }); }); @@ -185,7 +185,7 @@ describe(UserService.name, () => { new ImmichFileResponse({ path: '/path/to/profile.jpg', contentType: 'image/jpeg', - cacheControl: CacheControl.NONE, + cacheControl: CacheControl.None, }), ); @@ -211,7 +211,7 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); expect(mocks.user.getDeletedAfter).toHaveBeenCalled(); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.USER_DELETION, data: { id: user.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.UserDeletion, data: { id: user.id } }]); }); }); @@ -266,7 +266,7 @@ describe(UserService.name, () => { await sut.setLicense(authStub.user1, license); expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { - key: UserMetadataKey.LICENSE, + key: UserMetadataKey.License, value: expect.any(Object), }); }); @@ -279,7 +279,7 @@ describe(UserService.name, () => { await sut.setLicense(authStub.user1, license); expect(mocks.user.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { - key: UserMetadataKey.LICENSE, + key: UserMetadataKey.License, value: expect.any(Object), }); }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 78f49fd7ae..4a65e4ab9f 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -78,7 +78,7 @@ export class UserService extends BaseService { const updated = mergePreferences(getPreferences(metadata), dto); await this.userRepository.upsertMetadata(auth.user.id, { - key: UserMetadataKey.PREFERENCES, + key: UserMetadataKey.Preferences, value: getPreferencesPartial(updated), }); @@ -99,7 +99,7 @@ export class UserService extends BaseService { }); if (oldpath !== '') { - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } }); + await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [oldpath] } }); } return { @@ -115,7 +115,7 @@ export class UserService extends BaseService { throw new BadRequestException("Can't delete a missing profile Image"); } await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() }); - await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } }); + await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [user.profileImagePath] } }); } async getProfileImage(id: string): Promise { @@ -127,7 +127,7 @@ export class UserService extends BaseService { return new ImmichFileResponse({ path: user.profileImagePath, contentType: 'image/jpeg', - cacheControl: CacheControl.NONE, + cacheControl: CacheControl.None, }); } @@ -135,7 +135,7 @@ export class UserService extends BaseService { const metadata = await this.userRepository.getMetadata(auth.user.id); const license = metadata.find( - (item): item is UserMetadataItem => item.key === UserMetadataKey.LICENSE, + (item): item is UserMetadataItem => item.key === UserMetadataKey.License, ); if (!license) { throw new NotFoundException(); @@ -144,7 +144,7 @@ export class UserService extends BaseService { } async deleteLicense({ user }: AuthDto): Promise { - await this.userRepository.deleteMetadata(user.id, UserMetadataKey.LICENSE); + await this.userRepository.deleteMetadata(user.id, UserMetadataKey.License); } async setLicense(auth: AuthDto, license: LicenseKeyDto): Promise { @@ -173,7 +173,7 @@ export class UserService extends BaseService { const activatedAt = new Date(); await this.userRepository.upsertMetadata(auth.user.id, { - key: UserMetadataKey.LICENSE, + key: UserMetadataKey.License, value: { ...license, activatedAt: activatedAt.toISOString() }, }); @@ -184,7 +184,7 @@ export class UserService extends BaseService { const metadata = await this.userRepository.getMetadata(auth.user.id); const onboardingData = metadata.find( - (item): item is UserMetadataItem => item.key === UserMetadataKey.ONBOARDING, + (item): item is UserMetadataItem => item.key === UserMetadataKey.Onboarding, )?.value; if (!onboardingData) { @@ -197,12 +197,12 @@ export class UserService extends BaseService { } async deleteOnboarding({ user }: AuthDto): Promise { - await this.userRepository.deleteMetadata(user.id, UserMetadataKey.ONBOARDING); + await this.userRepository.deleteMetadata(user.id, UserMetadataKey.Onboarding); } async setOnboarding(auth: AuthDto, onboarding: OnboardingDto): Promise { await this.userRepository.upsertMetadata(auth.user.id, { - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, value: { isOnboarded: onboarding.isOnboarded, }, @@ -213,42 +213,42 @@ export class UserService extends BaseService { }; } - @OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.userSyncUsage, queue: QueueName.BackgroundTask }) async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); - return JobStatus.SUCCESS; + return JobStatus.Success; } - @OnJob({ name: JobName.USER_DELETE_CHECK, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.UserDeleteCheck, queue: QueueName.BackgroundTask }) async handleUserDeleteCheck(): Promise { const config = await this.getConfig({ withCache: false }); const users = await this.userRepository.getDeletedAfter(DateTime.now().minus({ days: config.user.deleteDelay })); - await this.jobRepository.queueAll(users.map((user) => ({ name: JobName.USER_DELETION, data: { id: user.id } }))); - return JobStatus.SUCCESS; + await this.jobRepository.queueAll(users.map((user) => ({ name: JobName.UserDeletion, data: { id: user.id } }))); + return JobStatus.Success; } - @OnJob({ name: JobName.USER_DELETION, queue: QueueName.BACKGROUND_TASK }) - async handleUserDelete({ id, force }: JobOf): Promise { + @OnJob({ name: JobName.UserDeletion, queue: QueueName.BackgroundTask }) + async handleUserDelete({ id, force }: JobOf): Promise { const config = await this.getConfig({ withCache: false }); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { - return JobStatus.FAILED; + return JobStatus.Failed; } // just for extra protection here if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) { this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); - return JobStatus.SKIPPED; + return JobStatus.Skipped; } this.logger.log(`Deleting user: ${user.id}`); const folders = [ StorageCore.getLibraryFolder(user), - StorageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), - StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id), - StorageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id), - StorageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id), + StorageCore.getFolderLocation(StorageFolder.Upload, user.id), + StorageCore.getFolderLocation(StorageFolder.Profile, user.id), + StorageCore.getFolderLocation(StorageFolder.Thumbnails, user.id), + StorageCore.getFolderLocation(StorageFolder.EncodedVideo, user.id), ]; for (const folder of folders) { @@ -260,7 +260,7 @@ export class UserService extends BaseService { await this.albumRepository.deleteAll(user.id); await this.userRepository.delete(user, true); - return JobStatus.SUCCESS; + return JobStatus.Success; } private isReadyForDeletion(user: { id: string; deletedAt?: Date | null }, deleteDelay: number): boolean { diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index a83d9f85b6..73794275ea 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -72,18 +72,18 @@ describe(VersionService.name, () => { describe('handQueueVersionCheck', () => { it('should queue a version check job', async () => { await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined(); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.VersionCheck, data: {} }); }); }); describe('handVersionCheck', () => { beforeEach(() => { - mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.PRODUCTION })); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production })); }); it('should not run in dev mode', async () => { - mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.DEVELOPMENT })); - await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development })); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped); }); it('should not run if the last check was < 60 minutes ago', async () => { @@ -91,12 +91,12 @@ describe(VersionService.name, () => { checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(), releaseVersion: '1.0.0', }); - await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped); }); it('should not run if version check is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ newVersionCheck: { enabled: false } }); - await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped); }); it('should run if it has been > 60 minutes', async () => { @@ -105,7 +105,7 @@ describe(VersionService.name, () => { checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(), releaseVersion: '1.0.0', }); - await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); expect(mocks.systemMetadata.set).toHaveBeenCalled(); expect(mocks.logger.log).toHaveBeenCalled(); expect(mocks.event.clientBroadcast).toHaveBeenCalled(); @@ -113,8 +113,8 @@ describe(VersionService.name, () => { it('should not notify if the version is equal', async () => { mocks.serverInfo.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString())); - await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, { + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success); + expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.VersionCheckState, { checkedAt: expect.any(String), releaseVersion: serverVersion.toString(), }); @@ -123,7 +123,7 @@ describe(VersionService.name, () => { it('should handle a github error', async () => { mocks.serverInfo.getGitHubRelease.mockRejectedValue(new Error('GitHub is down')); - await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED); + await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Failed); expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); expect(mocks.event.clientBroadcast).not.toHaveBeenCalled(); expect(mocks.logger.warn).toHaveBeenCalled(); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 51d31b623f..5d2f87c2bf 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -41,7 +41,7 @@ export class VersionService extends BaseService { const needsNewMemories = semver.lt(previousVersion, '1.129.0'); if (needsNewMemories) { - await this.jobRepository.queue({ name: JobName.MEMORIES_CREATE }); + await this.jobRepository.queue({ name: JobName.MemoriesCreate }); } } }); @@ -56,31 +56,31 @@ export class VersionService extends BaseService { } async handleQueueVersionCheck() { - await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); + await this.jobRepository.queue({ name: JobName.VersionCheck, data: {} }); } - @OnJob({ name: JobName.VERSION_CHECK, queue: QueueName.BACKGROUND_TASK }) + @OnJob({ name: JobName.VersionCheck, queue: QueueName.BackgroundTask }) async handleVersionCheck(): Promise { try { this.logger.debug('Running version check'); const { environment } = this.configRepository.getEnv(); - if (environment === ImmichEnvironment.DEVELOPMENT) { - return JobStatus.SKIPPED; + if (environment === ImmichEnvironment.Development) { + return JobStatus.Skipped; } const { newVersionCheck } = await this.getConfig({ withCache: true }); if (!newVersionCheck.enabled) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } - const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); + const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); if (versionCheck?.checkedAt) { const lastUpdate = DateTime.fromISO(versionCheck.checkedAt); const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes'); // check once per hour (max) if (elapsedTime < 60) { - return JobStatus.SKIPPED; + return JobStatus.Skipped; } } @@ -88,7 +88,7 @@ export class VersionService extends BaseService { await this.serverInfoRepository.getGitHubRelease(); const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion }; - await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata); + await this.systemMetadataRepository.set(SystemMetadataKey.VersionCheckState, metadata); if (semver.gt(releaseVersion, serverVersion)) { this.logger.log(`Found ${releaseVersion}, released at ${new Date(publishedAt).toLocaleString()}`); @@ -96,16 +96,16 @@ export class VersionService extends BaseService { } } catch (error: Error | any) { this.logger.warn(`Unable to run version check: ${error}`, error?.stack); - return JobStatus.FAILED; + return JobStatus.Failed; } - return JobStatus.SUCCESS; + return JobStatus.Success; } @OnEvent({ name: 'WebsocketConnect' }) async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) { this.eventRepository.clientSend('on_server_version', userId, serverVersion); - const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); + const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState); if (metadata) { this.eventRepository.clientSend('on_new_release', userId, asNotification(metadata)); } diff --git a/server/src/types.ts b/server/src/types.ts index 6776604078..6873613c30 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -161,10 +161,10 @@ export interface VideoInterfaces { export type ConcurrentQueueName = Exclude< QueueName, - | QueueName.STORAGE_TEMPLATE_MIGRATION - | QueueName.FACIAL_RECOGNITION - | QueueName.DUPLICATE_DETECTION - | QueueName.BACKUP_DATABASE + | QueueName.StorageTemplateMigration + | QueueName.FacialRecognition + | QueueName.DuplicateDetection + | QueueName.BackupDatabase >; export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] }; @@ -273,96 +273,96 @@ export interface QueueStatus { export type JobItem = // Backups - | { name: JobName.BACKUP_DATABASE; data?: IBaseJob } + | { name: JobName.BackupDatabase; data?: IBaseJob } // Transcoding - | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } - | { name: JobName.VIDEO_CONVERSION; data: IEntityJob } + | { name: JobName.QueueVideoConversion; data: IBaseJob } + | { name: JobName.VideoConversation; data: IEntityJob } // Thumbnails - | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } - | { name: JobName.GENERATE_THUMBNAILS; data: IEntityJob } + | { name: JobName.QueueGenerateThumbnails; data: IBaseJob } + | { name: JobName.GenerateThumbnails; data: IEntityJob } // User - | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } - | { name: JobName.USER_DELETION; data: IEntityJob } - | { name: JobName.USER_SYNC_USAGE; data?: IBaseJob } + | { name: JobName.UserDeleteCheck; data?: IBaseJob } + | { name: JobName.UserDeletion; data: IEntityJob } + | { name: JobName.userSyncUsage; data?: IBaseJob } // Storage Template - | { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob } - | { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE; data: IEntityJob } + | { name: JobName.StorageTemplateMigration; data?: IBaseJob } + | { name: JobName.StorageTemplateMigrationSingle; data: IEntityJob } // Migration - | { name: JobName.QUEUE_MIGRATION; data?: IBaseJob } - | { name: JobName.MIGRATE_ASSET; data: IEntityJob } - | { name: JobName.MIGRATE_PERSON; data: IEntityJob } + | { name: JobName.QueueMigration; data?: IBaseJob } + | { name: JobName.MigrateAsset; data: IEntityJob } + | { name: JobName.MigratePerson; data: IEntityJob } // Metadata Extraction - | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } - | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } + | { name: JobName.QueueMetadataExtraction; data: IBaseJob } + | { name: JobName.MetadataExtraction; data: IEntityJob } // Notifications - | { name: JobName.NOTIFICATIONS_CLEANUP; data?: IBaseJob } + | { name: JobName.NotificationsCleanup; data?: IBaseJob } // Sidecar Scanning - | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } - | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } - | { name: JobName.SIDECAR_SYNC; data: IEntityJob } - | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } + | { name: JobName.QueueSidecar; data: IBaseJob } + | { name: JobName.SidecarDiscovery; data: IEntityJob } + | { name: JobName.SidecarSync; data: IEntityJob } + | { name: JobName.SidecarWrite; data: ISidecarWriteJob } // Facial Recognition - | { name: JobName.QUEUE_FACE_DETECTION; data: IBaseJob } - | { name: JobName.FACE_DETECTION; data: IEntityJob } - | { name: JobName.QUEUE_FACIAL_RECOGNITION; data: INightlyJob } - | { name: JobName.FACIAL_RECOGNITION; data: IDeferrableJob } - | { name: JobName.GENERATE_PERSON_THUMBNAIL; data: IEntityJob } + | { name: JobName.QueueFaceDetection; data: IBaseJob } + | { name: JobName.FaceDetection; data: IEntityJob } + | { name: JobName.QueueFacialRecognition; data: INightlyJob } + | { name: JobName.FacialRecognition; data: IDeferrableJob } + | { name: JobName.GeneratePersonThumbnail; data: IEntityJob } // Smart Search - | { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob } - | { name: JobName.SMART_SEARCH; data: IEntityJob } - | { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob } + | { name: JobName.QueueSmartSearch; data: IBaseJob } + | { name: JobName.SmartSearch; data: IEntityJob } + | { name: JobName.QueueTrashEmpty; data?: IBaseJob } // Duplicate Detection - | { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob } - | { name: JobName.DUPLICATE_DETECTION; data: IEntityJob } + | { name: JobName.QueueDuplicateDetection; data: IBaseJob } + | { name: JobName.DuplicateDetection; data: IEntityJob } // Memories - | { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob } - | { name: JobName.MEMORIES_CREATE; data?: IBaseJob } + | { name: JobName.MemoriesCleanup; data?: IBaseJob } + | { name: JobName.MemoriesCreate; data?: IBaseJob } // Filesystem - | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } + | { name: JobName.DeleteFiles; data: IDeleteFilesJob } // Cleanup - | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } - | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } + | { name: JobName.CleanOldAuditLogs; data?: IBaseJob } + | { name: JobName.CleanOldSessionTokens; data?: IBaseJob } // Tags - | { name: JobName.TAG_CLEANUP; data?: IBaseJob } + | { name: JobName.TagCleanup; data?: IBaseJob } // Asset Deletion - | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } - | { name: JobName.ASSET_DELETION; data: IAssetDeleteJob } - | { name: JobName.ASSET_DELETION_CHECK; data?: IBaseJob } + | { name: JobName.PersonCleanup; data?: IBaseJob } + | { name: JobName.AssetDeletion; data: IAssetDeleteJob } + | { name: JobName.AssetDeletionCheck; data?: IBaseJob } // Library Management - | { name: JobName.LIBRARY_SYNC_FILES; data: ILibraryFileJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } - | { name: JobName.LIBRARY_SYNC_ASSETS; data: ILibraryBulkIdsJob } - | { name: JobName.LIBRARY_ASSET_REMOVAL; data: ILibraryFileJob } - | { name: JobName.LIBRARY_DELETE; data: IEntityJob } - | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data?: IBaseJob } - | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } + | { name: JobName.LibrarySyncFiles; data: ILibraryFileJob } + | { name: JobName.LibraryQueueSyncFiles; data: IEntityJob } + | { name: JobName.LibraryQueueSyncAssets; data: IEntityJob } + | { name: JobName.LibrarySyncAssets; data: ILibraryBulkIdsJob } + | { name: JobName.LibraryAssetRemoval; data: ILibraryFileJob } + | { name: JobName.LibraryDelete; data: IEntityJob } + | { name: JobName.LibraryQueueScanAll; data?: IBaseJob } + | { name: JobName.LibraryQueueCleanup; data: IBaseJob } // Notification - | { name: JobName.SEND_EMAIL; data: IEmailJob } - | { name: JobName.NOTIFY_ALBUM_INVITE; data: INotifyAlbumInviteJob } - | { name: JobName.NOTIFY_ALBUM_UPDATE; data: INotifyAlbumUpdateJob } - | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob } + | { name: JobName.SendMail; data: IEmailJob } + | { name: JobName.NotifyAlbumInvite; data: INotifyAlbumInviteJob } + | { name: JobName.NotifyAlbumUpdate; data: INotifyAlbumUpdateJob } + | { name: JobName.NotifySignup; data: INotifySignupJob } // Version check - | { name: JobName.VERSION_CHECK; data: IBaseJob }; + | { name: JobName.VersionCheck; data: IBaseJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; @@ -442,7 +442,7 @@ export type StorageAsset = { export type OnThisDayData = { year: number }; export interface MemoryData { - [MemoryType.ON_THIS_DAY]: OnThisDayData; + [MemoryType.OnThisDay]: OnThisDayData; } export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; @@ -453,14 +453,14 @@ export type MemoriesState = { }; export interface SystemMetadata extends Record> { - [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; - [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string }; - [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; - [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; - [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; - [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; - [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; - [SystemMetadataKey.MEMORIES_STATE]: MemoriesState; + [SystemMetadataKey.AdminOnboarding]: { isOnboarded: boolean }; + [SystemMetadataKey.FacialRecognitionState]: { lastRun?: string }; + [SystemMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [SystemMetadataKey.ReverseGeocodingState]: { lastUpdate?: string; lastImportFileName?: string }; + [SystemMetadataKey.SystemConfig]: DeepPartial; + [SystemMetadataKey.SystemFlags]: DeepPartial; + [SystemMetadataKey.VersionCheckState]: VersionCheckMetadata; + [SystemMetadataKey.MemoriesState]: MemoriesState; } export type UserMetadataItem = { @@ -513,7 +513,7 @@ export interface UserPreferences { } export interface UserMetadata extends Record> { - [UserMetadataKey.PREFERENCES]: DeepPartial; - [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: string }; - [UserMetadataKey.ONBOARDING]: { isOnboarded: boolean }; + [UserMetadataKey.Preferences]: DeepPartial; + [UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string }; + [UserMetadataKey.Onboarding]: { isOnboarded: boolean }; } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index b639643b6f..08ff81e840 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -11,7 +11,7 @@ export type GrantedRequest = { }; export const isGranted = ({ requested, current }: GrantedRequest) => { - if (current.includes(Permission.ALL)) { + if (current.includes(Permission.All)) { return true; } @@ -63,36 +63,36 @@ const checkSharedLinkAccess = async ( const sharedLinkId = sharedLink.id; switch (permission) { - case Permission.ASSET_READ: { + case Permission.AssetRead: { return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); } - case Permission.ASSET_VIEW: { + case Permission.AssetView: { return await access.asset.checkSharedLinkAccess(sharedLinkId, ids); } - case Permission.ASSET_DOWNLOAD: { + case Permission.AssetDownload: { return sharedLink.allowDownload ? await access.asset.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); } - case Permission.ASSET_UPLOAD: { + case Permission.AssetUpload: { return sharedLink.allowUpload ? ids : new Set(); } - case Permission.ASSET_SHARE: { + case Permission.AssetShare: { // TODO: fix this to not use sharedLink.userId for access control return await access.asset.checkOwnerAccess(sharedLink.userId, ids, false); } - case Permission.ALBUM_READ: { + case Permission.AlbumRead: { return await access.album.checkSharedLinkAccess(sharedLinkId, ids); } - case Permission.ALBUM_DOWNLOAD: { + case Permission.AlbumDownload: { return sharedLink.allowDownload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); } - case Permission.ALBUM_ADD_ASSET: { + case Permission.AlbumAddAsset: { return sharedLink.allowUpload ? await access.album.checkSharedLinkAccess(sharedLinkId, ids) : new Set(); } @@ -107,190 +107,190 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe switch (permission) { // uses album id - case Permission.ACTIVITY_CREATE: { + case Permission.ActivityCreate: { return await access.activity.checkCreateAccess(auth.user.id, ids); } // uses activity id - case Permission.ACTIVITY_DELETE: { + case Permission.ActivityDelete: { const isOwner = await access.activity.checkOwnerAccess(auth.user.id, ids); const isAlbumOwner = await access.activity.checkAlbumOwnerAccess(auth.user.id, setDifference(ids, isOwner)); return setUnion(isOwner, isAlbumOwner); } - case Permission.ASSET_READ: { + case Permission.AssetRead: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } - case Permission.ASSET_SHARE: { + case Permission.AssetShare: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); return setUnion(isOwner, isPartner); } - case Permission.ASSET_VIEW: { + case Permission.AssetView: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } - case Permission.ASSET_DOWNLOAD: { + case Permission.AssetDownload: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); return setUnion(isOwner, isAlbum, isPartner); } - case Permission.ASSET_UPDATE: { + case Permission.AssetUpdate: { return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } - case Permission.ASSET_DELETE: { + case Permission.AssetDelete: { return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } - case Permission.ALBUM_READ: { + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( auth.user.id, setDifference(ids, isOwner), - AlbumUserRole.VIEWER, + AlbumUserRole.Viewer, ); return setUnion(isOwner, isShared); } - case Permission.ALBUM_ADD_ASSET: { + case Permission.AlbumAddAsset: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( auth.user.id, setDifference(ids, isOwner), - AlbumUserRole.EDITOR, + AlbumUserRole.Editor, ); return setUnion(isOwner, isShared); } - case Permission.ALBUM_UPDATE: { + case Permission.AlbumUpdate: { return await access.album.checkOwnerAccess(auth.user.id, ids); } - case Permission.ALBUM_DELETE: { + case Permission.AlbumDelete: { return await access.album.checkOwnerAccess(auth.user.id, ids); } - case Permission.ALBUM_SHARE: { + case Permission.AlbumShare: { return await access.album.checkOwnerAccess(auth.user.id, ids); } - case Permission.ALBUM_DOWNLOAD: { + case Permission.AlbumDownload: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( auth.user.id, setDifference(ids, isOwner), - AlbumUserRole.VIEWER, + AlbumUserRole.Viewer, ); return setUnion(isOwner, isShared); } - case Permission.ALBUM_REMOVE_ASSET: { + case Permission.AlbumRemoveAsset: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( auth.user.id, setDifference(ids, isOwner), - AlbumUserRole.EDITOR, + AlbumUserRole.Editor, ); return setUnion(isOwner, isShared); } - case Permission.ASSET_UPLOAD: { + case Permission.AssetUpload: { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } - case Permission.ARCHIVE_READ: { + case Permission.ArchiveRead: { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } - case Permission.AUTH_DEVICE_DELETE: { + case Permission.AuthDeviceDelete: { return await access.authDevice.checkOwnerAccess(auth.user.id, ids); } - case Permission.FACE_DELETE: { + case Permission.FaceDelete: { return access.person.checkFaceOwnerAccess(auth.user.id, ids); } - case Permission.NOTIFICATION_READ: - case Permission.NOTIFICATION_UPDATE: - case Permission.NOTIFICATION_DELETE: { + case Permission.NotificationRead: + case Permission.NotificationUpdate: + case Permission.NotificationDelete: { return access.notification.checkOwnerAccess(auth.user.id, ids); } - case Permission.TAG_ASSET: - case Permission.TAG_READ: - case Permission.TAG_UPDATE: - case Permission.TAG_DELETE: { + case Permission.TagAsset: + case Permission.TagRead: + case Permission.TagUpdate: + case Permission.TagDelete: { return await access.tag.checkOwnerAccess(auth.user.id, ids); } - case Permission.TIMELINE_READ: { + case Permission.TimelineRead: { const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); return setUnion(isOwner, isPartner); } - case Permission.TIMELINE_DOWNLOAD: { + case Permission.TimelineDownload: { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } - case Permission.MEMORY_READ: { + case Permission.MemoryRead: { return access.memory.checkOwnerAccess(auth.user.id, ids); } - case Permission.MEMORY_UPDATE: { + case Permission.MemoryUpdate: { return access.memory.checkOwnerAccess(auth.user.id, ids); } - case Permission.MEMORY_DELETE: { + case Permission.MemoryDelete: { return access.memory.checkOwnerAccess(auth.user.id, ids); } - case Permission.PERSON_CREATE: { + case Permission.PersonCreate: { return access.person.checkFaceOwnerAccess(auth.user.id, ids); } - case Permission.PERSON_READ: - case Permission.PERSON_UPDATE: - case Permission.PERSON_DELETE: - case Permission.PERSON_MERGE: { + case Permission.PersonRead: + case Permission.PersonUpdate: + case Permission.PersonDelete: + case Permission.PersonMerge: { return await access.person.checkOwnerAccess(auth.user.id, ids); } - case Permission.PERSON_REASSIGN: { + case Permission.PersonReassign: { return access.person.checkFaceOwnerAccess(auth.user.id, ids); } - case Permission.PARTNER_UPDATE: { + case Permission.PartnerUpdate: { return await access.partner.checkUpdateAccess(auth.user.id, ids); } - case Permission.SESSION_READ: - case Permission.SESSION_UPDATE: - case Permission.SESSION_DELETE: - case Permission.SESSION_LOCK: { + case Permission.SessionRead: + case Permission.SessionUpdate: + case Permission.SessionDelete: + case Permission.SessionLock: { return access.session.checkOwnerAccess(auth.user.id, ids); } - case Permission.STACK_READ: { + case Permission.StackRead: { return access.stack.checkOwnerAccess(auth.user.id, ids); } - case Permission.STACK_UPDATE: { + case Permission.StackUpdate: { return access.stack.checkOwnerAccess(auth.user.id, ids); } - case Permission.STACK_DELETE: { + case Permission.StackDelete: { return access.stack.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 85bc6cd2e5..1b9e12c1cd 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -18,9 +18,9 @@ export const getAssetFile = (files: AssetFile[], type: AssetFileType | Generated }; export const getAssetFiles = (files: AssetFile[]) => ({ - fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE), - previewFile: getAssetFile(files, AssetFileType.PREVIEW), - thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL), + fullsizeFile: getAssetFile(files, AssetFileType.FullSize), + previewFile: getAssetFile(files, AssetFileType.Preview), + thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), }); export const addAssets = async ( @@ -33,7 +33,7 @@ export const addAssets = async ( const notPresentAssetIds = dto.assetIds.filter((id) => !existingAssetIds.has(id)); const allowedAssetIds = await checkAccess(access, { auth, - permission: Permission.ASSET_SHARE, + permission: Permission.AssetShare, ids: notPresentAssetIds, }); @@ -75,7 +75,7 @@ export const removeAssets = async ( const existingAssetIds = await bulk.getAssetIds(dto.parentId, dto.assetIds); const allowedAssetIds = canAlwaysRemove.has(dto.parentId) ? existingAssetIds - : await checkAccess(access, { auth, permission: Permission.ASSET_SHARE, ids: existingAssetIds }); + : await checkAccess(access, { auth, permission: Permission.AssetShare, ids: existingAssetIds }); const results: BulkIdResponseDto[] = []; for (const assetId of dto.assetIds) { @@ -143,15 +143,15 @@ export const onBeforeLink = async ( if (!motionAsset) { throw new BadRequestException('Live photo video not found'); } - if (motionAsset.type !== AssetType.VIDEO) { + if (motionAsset.type !== AssetType.Video) { throw new BadRequestException('Live photo video must be a video'); } if (motionAsset.ownerId !== userId) { throw new BadRequestException('Live photo video does not belong to the user'); } - if (motionAsset && motionAsset.visibility === AssetVisibility.TIMELINE) { - await assetRepository.update({ id: livePhotoVideoId, visibility: AssetVisibility.HIDDEN }); + if (motionAsset && motionAsset.visibility === AssetVisibility.Timeline) { + await assetRepository.update({ id: livePhotoVideoId, visibility: AssetVisibility.Hidden }); await eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId }); } }; diff --git a/server/src/utils/config.ts b/server/src/utils/config.ts index bc1d2dae1b..a669af31cf 100644 --- a/server/src/utils/config.ts +++ b/server/src/utils/config.ts @@ -60,7 +60,7 @@ export const updateConfig = async (repos: RepoDeps, newConfig: SystemConfig): Pr _.set(partialConfig, property, newValue); } - await metadataRepo.set(SystemMetadataKey.SYSTEM_CONFIG, partialConfig); + await metadataRepo.set(SystemMetadataKey.SystemConfig, partialConfig); return getConfig(repos, { withCache: false }); }; @@ -83,7 +83,7 @@ const buildConfig = async (repos: RepoDeps) => { // load partial const partial = configFile ? await loadFromFile(repos, configFile) - : await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG); + : await metadataRepo.get(SystemMetadataKey.SystemConfig); // merge with defaults const rawConfig = _.cloneDeep(defaults); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index f23a3deb35..1ef9b8e926 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -154,7 +154,7 @@ export function toJson(qb: SelectQueryBuilder) { - return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.ARCHIVE), sql.lit(AssetVisibility.TIMELINE)]); + return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]); } // TODO come up with a better query that only selects the fields we need @@ -299,7 +299,7 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); - const visibility = options.visibility == null ? AssetVisibility.TIMELINE : options.visibility; + const visibility = options.visibility == null ? AssetVisibility.Timeline : options.visibility; return kysely .withPlugin(joinDeduplicationPlugin) @@ -399,7 +399,7 @@ type VectorIndexQueryOptions = { table: string; vectorExtension: VectorExtension export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: VectorIndexQueryOptions): string { switch (vectorExtension) { - case DatabaseExtension.VECTORCHORD: { + case DatabaseExtension.VectorChord: { return ` CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vchordrq (embedding vector_cosine_ops) WITH (options = $$ residual_quantization = false @@ -410,7 +410,7 @@ export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: V sampling_factor = 1024 $$)`; } - case DatabaseExtension.VECTORS: { + case DatabaseExtension.Vectors: { return ` CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING vectors (embedding vector_cos_ops) WITH (options = $$ @@ -420,7 +420,7 @@ export function vectorIndexQuery({ vectorExtension, table, indexName, lists }: V ef_construction = 300 $$)`; } - case DatabaseExtension.VECTOR: { + case DatabaseExtension.Vector: { return ` CREATE INDEX IF NOT EXISTS ${indexName} ON ${table} USING hnsw (embedding vector_cosine_ops) diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 716e0b1957..3e1a1b7f68 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -34,9 +34,9 @@ type SendFile = Parameters; type SendFileOptions = SendFile[1]; const cacheControlHeaders: Record = { - [CacheControl.PRIVATE_WITH_CACHE]: 'private, max-age=86400, no-transform', - [CacheControl.PRIVATE_WITHOUT_CACHE]: 'private, no-cache, no-transform', - [CacheControl.NONE]: null, // falsy value to prevent adding Cache-Control header + [CacheControl.PrivateWithCache]: 'private, max-age=86400, no-transform', + [CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform', + [CacheControl.None]: null, // falsy value to prevent adding Cache-Control header }; export const sendFile = async ( diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index b00eb652ef..e43ecba49f 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,5 +1,5 @@ import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/enum'; +import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum'; import { AudioStreamInfo, BitrateDistribution, @@ -16,7 +16,7 @@ export class BaseConfig implements VideoCodecSWConfig { protected constructor(protected config: SystemConfigFFmpegDto) {} static create(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces): VideoCodecSWConfig { - if (config.accel === TranscodeHWAccel.DISABLED) { + if (config.accel === TranscodeHardwareAcceleration.Disabled) { return this.getSWCodecConfig(config); } return this.getHWCodecConfig(config, interfaces); @@ -27,13 +27,13 @@ export class BaseConfig implements VideoCodecSWConfig { case VideoCodec.H264: { return new H264Config(config); } - case VideoCodec.HEVC: { + case VideoCodec.Hevc: { return new HEVCConfig(config); } - case VideoCodec.VP9: { + case VideoCodec.Vp9: { return new VP9Config(config); } - case VideoCodec.AV1: { + case VideoCodec.Av1: { return new AV1Config(config); } default: { @@ -45,25 +45,25 @@ export class BaseConfig implements VideoCodecSWConfig { private static getHWCodecConfig(config: SystemConfigFFmpegDto, interfaces: VideoInterfaces) { let handler: VideoCodecHWConfig; switch (config.accel) { - case TranscodeHWAccel.NVENC: { + case TranscodeHardwareAcceleration.Nvenc: { handler = config.accelDecode ? new NvencHwDecodeConfig(config, interfaces) : new NvencSwDecodeConfig(config, interfaces); break; } - case TranscodeHWAccel.QSV: { + case TranscodeHardwareAcceleration.Qsv: { handler = config.accelDecode ? new QsvHwDecodeConfig(config, interfaces) : new QsvSwDecodeConfig(config, interfaces); break; } - case TranscodeHWAccel.VAAPI: { + case TranscodeHardwareAcceleration.Vaapi: { handler = config.accelDecode ? new VaapiHwDecodeConfig(config, interfaces) : new VaapiSwDecodeConfig(config, interfaces); break; } - case TranscodeHWAccel.RKMPP: { + case TranscodeHardwareAcceleration.Rkmpp: { handler = config.accelDecode ? new RkmppHwDecodeConfig(config, interfaces) : new RkmppSwDecodeConfig(config, interfaces); @@ -94,7 +94,7 @@ export class BaseConfig implements VideoCodecSWConfig { twoPass: this.eligibleForTwoPass(), progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; - if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { + if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) { const filters = this.getFilterOptions(videoStream); if (filters.length > 0) { options.outputOptions.push(`-vf ${filters.join(',')}`); @@ -116,8 +116,8 @@ export class BaseConfig implements VideoCodecSWConfig { } getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { - const videoCodec = [TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy'; - const audioCodec = [TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy'; + const videoCodec = [TranscodeTarget.All, TranscodeTarget.Video].includes(target) ? this.getVideoCodec() : 'copy'; + const audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioCodec() : 'copy'; const options = [ `-c:v ${videoCodec}`, @@ -146,7 +146,7 @@ export class BaseConfig implements VideoCodecSWConfig { } if ( - this.config.targetVideoCodec === VideoCodec.HEVC && + this.config.targetVideoCodec === VideoCodec.Hevc && (videoCodec !== 'copy' || videoStream.codecName === 'hevc') ) { options.push('-tag:v hvc1'); @@ -207,7 +207,7 @@ export class BaseConfig implements VideoCodecSWConfig { } eligibleForTwoPass() { - if (!this.config.twoPass || this.config.accel !== TranscodeHWAccel.DISABLED) { + if (!this.config.twoPass || this.config.accel !== TranscodeHardwareAcceleration.Disabled) { return false; } @@ -244,7 +244,7 @@ export class BaseConfig implements VideoCodecSWConfig { } shouldToneMap(videoStream: VideoStreamInfo) { - return videoStream.isHDR && this.config.tonemap !== ToneMapping.DISABLED; + return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled; } getScaling(videoStream: VideoStreamInfo, mult = 2) { @@ -326,7 +326,7 @@ export class BaseConfig implements VideoCodecSWConfig { } useCQP() { - return this.config.cqMode === CQMode.CQP; + return this.config.cqMode === CQMode.Cqp; } } @@ -344,7 +344,7 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC]; + return [VideoCodec.H264, VideoCodec.Hevc]; } validateDevices(devices: string[]) { @@ -526,7 +526,7 @@ export class NvencSwDecodeConfig extends BaseHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; + return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Av1]; } getBaseInputOptions() { @@ -658,7 +658,7 @@ export class QsvSwDecodeConfig extends BaseHWConfig { getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { const options = super.getBaseOutputOptions(target, videoStream, audioStream); // VP9 requires enabling low power mode https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/33583803e107b6d532def0f9d949364b01b6ad5a - if (this.config.targetVideoCodec === VideoCodec.VP9) { + if (this.config.targetVideoCodec === VideoCodec.Vp9) { options.push('-low_power 1'); } return options; @@ -693,7 +693,7 @@ export class QsvSwDecodeConfig extends BaseHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9, VideoCodec.AV1]; + return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1]; } // recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md @@ -712,7 +712,7 @@ export class QsvSwDecodeConfig extends BaseHWConfig { } useCQP() { - return this.config.cqMode === CQMode.CQP || this.config.targetVideoCodec === VideoCodec.VP9; + return this.config.cqMode === CQMode.Cqp || this.config.targetVideoCodec === VideoCodec.Vp9; } getScaling(videoStream: VideoStreamInfo): string { @@ -802,7 +802,7 @@ export class VaapiSwDecodeConfig extends BaseHWConfig { const bitrates = this.getBitrateDistribution(); const options = []; - if (this.config.targetVideoCodec === VideoCodec.VP9) { + if (this.config.targetVideoCodec === VideoCodec.Vp9) { options.push('-bsf:v vp9_raw_reorder,vp9_superframe'); } @@ -824,11 +824,11 @@ export class VaapiSwDecodeConfig extends BaseHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9, VideoCodec.AV1]; + return [VideoCodec.H264, VideoCodec.Hevc, VideoCodec.Vp9, VideoCodec.Av1]; } useCQP() { - return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9; + return this.config.cqMode !== CQMode.Icq || this.config.targetVideoCodec === VideoCodec.Vp9; } } @@ -900,7 +900,7 @@ export class RkmppSwDecodeConfig extends BaseHWConfig { // from ffmpeg_mpp help, commonly referred to as H264 level 5.1 return ['-level 51']; } - case VideoCodec.HEVC: { + case VideoCodec.Hevc: { // from ffmpeg_mpp help, commonly referred to as HEVC level 5.1 return ['-level 153']; } @@ -921,7 +921,7 @@ export class RkmppSwDecodeConfig extends BaseHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC]; + return [VideoCodec.H264, VideoCodec.Hevc]; } getVideoCodec(): string { diff --git a/server/src/utils/mime-types.ts b/server/src/utils/mime-types.ts index 6aad418d9f..6b9392146d 100644 --- a/server/src/utils/mime-types.ts +++ b/server/src/utils/mime-types.ts @@ -129,11 +129,11 @@ export const mimeTypes = { assetType: (filename: string) => { const contentType = lookup(filename); if (contentType.startsWith('image/')) { - return AssetType.IMAGE; + return AssetType.Image; } else if (contentType.startsWith('video/')) { - return AssetType.VIDEO; + return AssetType.Video; } - return AssetType.OTHER; + return AssetType.Other; }, getSupportedFileExtensions: () => [...Object.keys(image), ...Object.keys(video)], }; diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 742e98c1c3..3acb72b663 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -234,14 +234,14 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean }) scheme: 'Bearer', in: 'header', }) - .addCookieAuth(ImmichCookie.ACCESS_TOKEN) + .addCookieAuth(ImmichCookie.AccessToken) .addApiKey( { type: 'apiKey', in: 'header', - name: ImmichHeader.API_KEY, + name: ImmichHeader.ApiKey, }, - MetadataKey.API_KEY_SECURITY, + MetadataKey.ApiKeySecurity, ) .addServer('/api') .build(); diff --git a/server/src/utils/preferences.ts b/server/src/utils/preferences.ts index 9bd3dedd52..121bf2826d 100644 --- a/server/src/utils/preferences.ts +++ b/server/src/utils/preferences.ts @@ -8,7 +8,7 @@ import { getKeysDeep } from 'src/utils/misc'; const getDefaultPreferences = (): UserPreferences => { return { albums: { - defaultAssetOrder: AssetOrder.DESC, + defaultAssetOrder: AssetOrder.Desc, }, folders: { enabled: false, @@ -53,7 +53,7 @@ const getDefaultPreferences = (): UserPreferences => { export const getPreferences = (metadata: UserMetadataItem[]): UserPreferences => { const preferences = getDefaultPreferences(); - const item = metadata.find(({ key }) => key === UserMetadataKey.PREFERENCES); + const item = metadata.find(({ key }) => key === UserMetadataKey.Preferences); const partial = item?.value || {}; for (const property of getKeysDeep(partial)) { _.set(preferences, property, _.get(partial, property)); diff --git a/server/src/utils/response.ts b/server/src/utils/response.ts index a50e86a4ff..c5f51c385c 100644 --- a/server/src/utils/response.ts +++ b/server/src/utils/response.ts @@ -13,13 +13,13 @@ export const respondWithCookie = (res: Response, body: T, { isSecure, values }; const cookieOptions: Record = { - [ImmichCookie.AUTH_TYPE]: defaults, - [ImmichCookie.ACCESS_TOKEN]: defaults, - [ImmichCookie.OAUTH_STATE]: defaults, - [ImmichCookie.OAUTH_CODE_VERIFIER]: defaults, + [ImmichCookie.AuthType]: defaults, + [ImmichCookie.AccessToken]: defaults, + [ImmichCookie.OAuthState]: defaults, + [ImmichCookie.OAuthCodeVerifier]: defaults, // no httpOnly so that the client can know the auth state - [ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false }, - [ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() }, + [ImmichCookie.IsAuthenticated]: { ...defaults, httpOnly: false }, + [ImmichCookie.SharedLinkToken]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() }, }; for (const { key, value } of values) { diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index fd6a8678a0..d36989bbcf 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -19,7 +19,7 @@ export const albumStub = { sharedLinks: [], albumUsers: [], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), sharedWithUser: Object.freeze({ @@ -38,11 +38,11 @@ export const albumStub = { albumUsers: [ { user: userStub.user1, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }, ], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), sharedWithMultiple: Object.freeze({ @@ -61,15 +61,15 @@ export const albumStub = { albumUsers: [ { user: userStub.user1, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }, { user: userStub.user2, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }, ], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), sharedWithAdmin: Object.freeze({ @@ -88,11 +88,11 @@ export const albumStub = { albumUsers: [ { user: userStub.admin, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }, ], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), oneAsset: Object.freeze({ @@ -110,7 +110,7 @@ export const albumStub = { sharedLinks: [], albumUsers: [], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), twoAssets: Object.freeze({ @@ -128,7 +128,7 @@ export const albumStub = { sharedLinks: [], albumUsers: [], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), emptyWithValidThumbnail: Object.freeze({ @@ -146,7 +146,7 @@ export const albumStub = { sharedLinks: [], albumUsers: [], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, updateId: '42', }), }; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index aa38a520ee..991c5d2c4f 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -8,19 +8,19 @@ import { userStub } from 'test/fixtures/user.stub'; export const previewFile: AssetFile = { id: 'file-1', - type: AssetFileType.PREVIEW, + type: AssetFileType.Preview, path: '/uploads/user-id/thumbs/path.jpg', }; const thumbnailFile: AssetFile = { id: 'file-2', - type: AssetFileType.THUMBNAIL, + type: AssetFileType.Thumbnail, path: '/uploads/user-id/webp/path.ext', }; const fullsizeFile: AssetFile = { id: 'file-3', - type: AssetFileType.FULLSIZE, + type: AssetFileType.FullSize, path: '/uploads/user-id/fullsize/path.webp', }; @@ -44,7 +44,7 @@ export const assetStub = { id: 'asset-id', ownerId: 'user-id', livePhotoVideoId: null, - type: AssetType.IMAGE, + type: AssetType.Image, isExternal: false, checksum: Buffer.from('file hash'), timeZone: null, @@ -57,7 +57,7 @@ export const assetStub = { }), noResizePath: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, originalFileName: 'IMG_123.jpg', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -68,7 +68,7 @@ export const assetStub = { originalPath: 'upload/library/IMG_123.jpg', files: [thumbnailFile], checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -89,12 +89,12 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), noWebpPath: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -104,7 +104,7 @@ export const assetStub = { originalPath: 'upload/library/IMG_456.jpg', files: [previewFile], checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -128,12 +128,12 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), noThumbhash: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -143,7 +143,7 @@ export const assetStub = { originalPath: '/original/path.ext', files, checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, thumbhash: null, encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -164,12 +164,12 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), primaryImage: Object.freeze({ id: 'primary-asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -179,7 +179,7 @@ export const assetStub = { originalPath: '/original/path.jpg', checksum: Buffer.from('file hash', 'utf8'), files, - type: AssetType.IMAGE, + type: AssetType.Image, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -210,12 +210,12 @@ export const assetStub = { isOffline: false, updateId: '42', libraryId: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), image: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -225,7 +225,7 @@ export const assetStub = { originalPath: '/original/path.jpg', files, checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -256,7 +256,7 @@ export const assetStub = { projectionType: null, height: 3840, width: 2160, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), trashed: Object.freeze({ @@ -269,7 +269,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.jpg', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -293,16 +293,16 @@ export const assetStub = { } as Exif, duplicateId: null, isOffline: false, - status: AssetStatus.TRASHED, + status: AssetStatus.Trashed, libraryId: null, stackId: null, updateId: '42', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), trashedOffline: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -311,7 +311,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.jpg', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -338,11 +338,11 @@ export const assetStub = { isOffline: true, stackId: null, updateId: '42', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), archived: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -351,7 +351,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.jpg', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -378,12 +378,12 @@ export const assetStub = { libraryId: null, stackId: null, updateId: '42', - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), external: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -392,7 +392,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', checksum: Buffer.from('path hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -418,12 +418,12 @@ export const assetStub = { updateId: '42', stackId: null, stack: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), image1: Object.freeze({ id: 'asset-id-1', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -432,7 +432,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.ext', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -458,12 +458,12 @@ export const assetStub = { stackId: null, libraryId: null, stack: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), imageFrom2015: Object.freeze({ id: 'asset-id-1', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'), @@ -473,7 +473,7 @@ export const assetStub = { originalPath: '/original/path.ext', files, checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, createdAt: new Date('2015-02-23T05:06:29.716Z'), @@ -494,12 +494,12 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), video: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, originalFileName: 'asset-id.ext', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -509,7 +509,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.ext', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.VIDEO, + type: AssetType.Video, files: [previewFile], thumbhash: null, encodedVideoPath: null, @@ -535,15 +535,15 @@ export const assetStub = { updateId: '42', libraryId: null, stackId: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), livePhotoMotionAsset: Object.freeze({ - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, id: fileStub.livePhotoMotion.uuid, originalPath: fileStub.livePhotoMotion.originalPath, ownerId: authStub.user1.user.id, - type: AssetType.VIDEO, + type: AssetType.Video, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), exifInfo: { @@ -551,15 +551,15 @@ export const assetStub = { timeZone: `America/New_York`, }, libraryId: null, - visibility: AssetVisibility.HIDDEN, + visibility: AssetVisibility.Hidden, } as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }), livePhotoStillAsset: Object.freeze({ id: 'live-photo-still-asset', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, originalPath: fileStub.livePhotoStill.originalPath, ownerId: authStub.user1.user.id, - type: AssetType.IMAGE, + type: AssetType.Image, livePhotoVideoId: 'live-photo-motion-asset', fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), @@ -569,16 +569,16 @@ export const assetStub = { }, files, faces: [] as AssetFace[], - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, } as MapAsset & { faces: AssetFace[] }), livePhotoWithOriginalFileName: Object.freeze({ id: 'live-photo-still-asset', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, originalPath: fileStub.livePhotoStill.originalPath, originalFileName: fileStub.livePhotoStill.originalName, ownerId: authStub.user1.user.id, - type: AssetType.IMAGE, + type: AssetType.Image, livePhotoVideoId: 'live-photo-motion-asset', fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), @@ -588,12 +588,12 @@ export const assetStub = { }, libraryId: null, faces: [] as AssetFace[], - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, } as MapAsset & { faces: AssetFace[] }), withLocation: Object.freeze({ id: 'asset-with-favorite-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-22T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-22T05:06:29.716Z'), @@ -603,7 +603,7 @@ export const assetStub = { checksum: Buffer.from('file hash', 'utf8'), originalPath: '/original/path.ext', sidecarPath: null, - type: AssetType.IMAGE, + type: AssetType.Image, files: [previewFile], thumbhash: null, encodedVideoPath: null, @@ -633,12 +633,12 @@ export const assetStub = { duplicateId: null, isOffline: false, tags: [], - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), sidecar: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -648,7 +648,7 @@ export const assetStub = { originalPath: '/original/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -669,12 +669,12 @@ export const assetStub = { updateId: 'foo', libraryId: null, stackId: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), sidecarWithoutExt: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -684,7 +684,7 @@ export const assetStub = { originalPath: '/original/path.ext', thumbhash: null, checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files: [previewFile], encodedVideoPath: null, createdAt: new Date('2023-02-23T05:06:29.716Z'), @@ -702,12 +702,12 @@ export const assetStub = { deletedAt: null, duplicateId: null, isOffline: false, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), hasEncodedVideo: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, originalFileName: 'asset-id.ext', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -717,7 +717,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.ext', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.VIDEO, + type: AssetType.Video, files: [previewFile], thumbhash: null, encodedVideoPath: '/encoded/video/path.mp4', @@ -742,12 +742,12 @@ export const assetStub = { libraryId: null, stackId: null, stack: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), hasFileExtension: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -756,7 +756,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/data/user1/photo.jpg', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -779,12 +779,12 @@ export const assetStub = { } as Exif, duplicateId: null, isOffline: false, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), imageDng: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -793,7 +793,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.dng', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -820,12 +820,12 @@ export const assetStub = { updateId: '42', libraryId: null, stackId: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), imageHif: Object.freeze({ id: 'asset-id', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), @@ -834,7 +834,7 @@ export const assetStub = { deviceId: 'device-id', originalPath: '/original/path.hif', checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.IMAGE, + type: AssetType.Image, files, thumbhash: Buffer.from('blablabla', 'base64'), encodedVideoPath: null, @@ -861,6 +861,6 @@ export const assetStub = { updateId: '42', libraryId: null, stackId: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }), }; diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index fe5cbb9a56..beecf7c69e 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -20,7 +20,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, deletedAt: new Date(), }), @@ -36,7 +36,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), @@ -52,7 +52,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), @@ -68,7 +68,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), @@ -84,7 +84,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, deletedAt: null, }), @@ -100,7 +100,7 @@ export const faceStub = { boundingBoxY2: 200, imageHeight: 500, imageWidth: 400, - sourceType: SourceType.EXIF, + sourceType: SourceType.Exif, deletedAt: null, }), fromExif2: Object.freeze({ @@ -115,7 +115,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.EXIF, + sourceType: SourceType.Exif, deletedAt: null, }), withBirthDate: Object.freeze({ @@ -130,7 +130,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, - sourceType: SourceType.MACHINE_LEARNING, + sourceType: SourceType.MachineLearning, deletedAt: null, }), }; diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 86f3bcde21..35a7a8ed7d 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -176,7 +176,7 @@ export const personThumbnailStub = { y2: 505, oldHeight: 2880, oldWidth: 2160, - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', previewPath: previewFile.path, @@ -189,7 +189,7 @@ export const personThumbnailStub = { y2: 200, oldHeight: 500, oldWidth: 400, - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', previewPath: previewFile.path, @@ -202,7 +202,7 @@ export const personThumbnailStub = { y2: 495, oldHeight: 500, oldWidth: 500, - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', previewPath: previewFile.path, @@ -215,7 +215,7 @@ export const personThumbnailStub = { y2: 200, oldHeight: 500, oldWidth: 400, - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/original/path.dng', exifOrientation: '1', previewPath: previewFile.path, @@ -228,7 +228,7 @@ export const personThumbnailStub = { y2: 251, oldHeight: 1440, oldWidth: 2162, - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', previewPath: previewFile.path, @@ -241,7 +241,7 @@ export const personThumbnailStub = { y2: 152, oldHeight: 1440, oldWidth: 2162, - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/original/path.jpg', exifOrientation: '1', previewPath: previewFile.path, @@ -254,7 +254,7 @@ export const personThumbnailStub = { y2: 200, oldHeight: 500, oldWidth: 400, - type: AssetType.VIDEO, + type: AssetType.Video, originalPath: '/original/path.mp4', exifOrientation: '1', previewPath: previewFile.path, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index f3096280d9..47201a5b3b 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -49,7 +49,7 @@ const assetResponse: AssetResponseDto = { deviceAssetId: 'device_asset_id_1', ownerId: 'user_id_1', deviceId: 'device_id_1', - type: AssetType.VIDEO, + type: AssetType.Video, originalMimeType: 'image/jpeg', originalPath: 'fake_path/jpeg', originalFileName: 'asset_1.jpeg', @@ -70,12 +70,12 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }; const assetResponseWithoutMetadata = { id: 'id_1', - type: AssetType.VIDEO, + type: AssetType.Video, originalMimeType: 'image/jpeg', thumbhash: null, localDateTime: today, @@ -99,7 +99,7 @@ const albumResponse: AlbumResponseDto = { assets: [], assetCount: 1, isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, }; export const sharedLinkStub = { @@ -107,7 +107,7 @@ export const sharedLinkStub = { id: '123', userId: authStub.admin.user.id, key: sharedLinkBytes, - type: SharedLinkType.INDIVIDUAL, + type: SharedLinkType.Individual, createdAt: today, expiresAt: tomorrow, allowUpload: true, @@ -124,7 +124,7 @@ export const sharedLinkStub = { userId: authStub.admin.user.id, user: userStub.admin, key: sharedLinkBytes, - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, createdAt: today, expiresAt: tomorrow, allowUpload: true, @@ -141,7 +141,7 @@ export const sharedLinkStub = { userId: authStub.admin.user.id, user: userStub.admin, key: sharedLinkBytes, - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, createdAt: today, expiresAt: yesterday, allowUpload: true, @@ -157,7 +157,7 @@ export const sharedLinkStub = { id: '123', userId: authStub.admin.user.id, key: sharedLinkBytes, - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, createdAt: today, expiresAt: tomorrow, allowUpload: false, @@ -182,16 +182,16 @@ export const sharedLinkStub = { albumUsers: [], sharedLinks: [], isActivityEnabled: true, - order: AssetOrder.DESC, + order: AssetOrder.Desc, assets: [ { id: 'id_1', - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, owner: undefined as unknown as UserAdmin, ownerId: 'user_id_1', deviceAssetId: 'device_asset_id_1', deviceId: 'device_id_1', - type: AssetType.VIDEO, + type: AssetType.Video, originalPath: 'fake_path/jpeg', checksum: Buffer.from('file hash', 'utf8'), fileModifiedAt: today, @@ -251,7 +251,7 @@ export const sharedLinkStub = { updateId: '42', libraryId: null, stackId: null, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }, ], }, @@ -260,7 +260,7 @@ export const sharedLinkStub = { id: '123', userId: authStub.admin.user.id, key: sharedLinkBytes, - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, createdAt: today, expiresAt: tomorrow, allowUpload: true, @@ -286,7 +286,7 @@ export const sharedLinkResponseStub = { id: '123', key: sharedLinkBytes.toString('base64url'), showMetadata: true, - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, userId: 'admin_id', }), expired: Object.freeze({ @@ -301,14 +301,14 @@ export const sharedLinkResponseStub = { id: '123', key: sharedLinkBytes.toString('base64url'), showMetadata: true, - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, userId: 'admin_id', }), readonlyNoMetadata: Object.freeze({ id: '123', userId: 'admin_id', key: sharedLinkBytes.toString('base64url'), - type: SharedLinkType.ALBUM, + type: SharedLinkType.Album, createdAt: today, expiresAt: tomorrow, description: null, diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 0db58e2eed..807da5197f 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -5,7 +5,7 @@ import { authStub } from 'test/fixtures/auth.stub'; export const userStub = { admin: { ...authStub.admin.user, - status: UserStatus.ACTIVE, + status: UserStatus.Active, profileChangedAt: new Date('2021-01-01'), name: 'admin_name', id: 'admin_id', @@ -23,7 +23,7 @@ export const userStub = { }, user1: { ...authStub.user1.user, - status: UserStatus.ACTIVE, + status: UserStatus.Active, profileChangedAt: new Date('2021-01-01'), name: 'immich_name', storageLabel: null, @@ -40,7 +40,7 @@ export const userStub = { }, user2: { ...authStub.user2.user, - status: UserStatus.ACTIVE, + status: UserStatus.Active, profileChangedAt: new Date('2021-01-01'), metadata: [], name: 'immich_name', diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 9c1032663f..4d13264fa2 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -182,7 +182,7 @@ export class MediumTestContext { } async newAlbumUser(dto: { albumId: string; userId: string; role?: AlbumUserRole }) { - const { albumId, userId, role = AlbumUserRole.EDITOR } = dto; + const { albumId, userId, role = AlbumUserRole.Editor } = dto; const result = await this.get(AlbumUserRepository).create({ albumsId: albumId, usersId: userId, role }); return { albumUser: { albumId, userId, role }, result }; } @@ -370,14 +370,14 @@ const assetInsert = (asset: Partial> = {}) => { deviceId: '', originalFileName: '', checksum: randomBytes(32), - type: AssetType.IMAGE, + type: AssetType.Image, originalPath: '/path/to/something.jpg', ownerId: '@immich.cloud', isFavorite: false, fileCreatedAt: now, fileModifiedAt: now, localDateTime: now, - visibility: AssetVisibility.TIMELINE, + visibility: AssetVisibility.Timeline, }; return { @@ -423,7 +423,7 @@ const assetFaceInsert = (assetFace: Partial & { assetId: string }) => imageHeight: assetFace.imageHeight ?? 10, imageWidth: assetFace.imageWidth ?? 10, personId: assetFace.personId ?? null, - sourceType: assetFace.sourceType ?? SourceType.MACHINE_LEARNING, + sourceType: assetFace.sourceType ?? SourceType.MachineLearning, }; return { @@ -516,7 +516,7 @@ const memoryInsert = (memory: Partial> = {}) => { createdAt: date, updatedAt: date, deletedAt: null, - type: MemoryType.ON_THIS_DAY, + type: MemoryType.OnThisDay, data: { year: 2025 }, showAt: null, hideAt: null, diff --git a/server/test/medium/specs/services/auth.service.spec.ts b/server/test/medium/specs/services/auth.service.spec.ts index f1d50ce841..14ea1451f2 100644 --- a/server/test/medium/specs/services/auth.service.spec.ts +++ b/server/test/medium/specs/services/auth.service.spec.ts @@ -102,7 +102,7 @@ describe(AuthService.name, () => { it('should logout', async () => { const { sut } = setup(); const auth = factory.auth(); - await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ + await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); @@ -118,7 +118,7 @@ describe(AuthService.name, () => { eventRepo.emit.mockResolvedValue(); await expect(sessionRepo.get(session.id)).resolves.toEqual(expect.objectContaining({ id: session.id })); - await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ + await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0', }); diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index bdd06b4a3f..12df2f130e 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -45,7 +45,7 @@ describe(MemoryService.name, () => { const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const dto = { - type: MemoryType.ON_THIS_DAY, + type: MemoryType.OnThisDay, data: { year: 2021 }, memoryAt: new Date(2021), }; @@ -70,7 +70,7 @@ describe(MemoryService.name, () => { const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); const auth = factory.auth({ user }); const dto = { - type: MemoryType.ON_THIS_DAY, + type: MemoryType.OnThisDay, data: { year: 2021 }, memoryAt: new Date(2021), assetIds: [asset1.id, asset2.id], @@ -92,7 +92,7 @@ describe(MemoryService.name, () => { const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id }); const auth = factory.auth({ user: user1 }); const dto = { - type: MemoryType.ON_THIS_DAY, + type: MemoryType.OnThisDay, data: { year: 2021 }, memoryAt: new Date(2021), assetIds: [asset1.id, asset2.id], @@ -124,8 +124,8 @@ describe(MemoryService.name, () => { ctx.newExif({ assetId: asset.id, make: 'Canon' }), ctx.newJobStatus({ assetId: asset.id }), assetRepo.upsertFiles([ - { assetId: asset.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' }, - { assetId: asset.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' }, + { assetId: asset.id, type: AssetFileType.Preview, path: '/path/to/preview.jpg' }, + { assetId: asset.id, type: AssetFileType.Thumbnail, path: '/path/to/thumbnail.jpg' }, ]), ]); @@ -178,8 +178,8 @@ describe(MemoryService.name, () => { ctx.newExif({ assetId: asset.id, make: 'Canon' }), ctx.newJobStatus({ assetId: asset.id }), assetRepo.upsertFiles([ - { assetId: asset.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' }, - { assetId: asset.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' }, + { assetId: asset.id, type: AssetFileType.Preview, path: '/path/to/preview.jpg' }, + { assetId: asset.id, type: AssetFileType.Thumbnail, path: '/path/to/thumbnail.jpg' }, ]), ]); } diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts index 6af936eb49..fa4a75e869 100644 --- a/server/test/medium/specs/services/timeline.service.spec.ts +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -46,7 +46,7 @@ describe(TimelineService.name, () => { it('should return error if time bucket is requested with partners asset and archived', async () => { const { sut } = setup(); const auth = factory.auth(); - const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.ARCHIVE }); + const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive }); await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toThrow( 'withPartners is only supported for non-archived, non-trashed, non-favorited assets', diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index 535ac30aec..f5bddca196 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -16,7 +16,7 @@ import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; const setup = (db?: Kysely) => { - process.env.IMMICH_ENV = ImmichEnvironment.TESTING; + process.env.IMMICH_ENV = ImmichEnvironment.Testing; return newMediumService(UserService, { database: db || defaultDatabase, @@ -140,7 +140,7 @@ describe(UserService.name, () => { const { sut, ctx } = setup(); const jobMock = ctx.getMock(JobRepository); jobMock.queueAll.mockResolvedValue(void 0); - await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS); + await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.Success); expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]); }); @@ -149,10 +149,8 @@ describe(UserService.name, () => { const jobMock = ctx.getMock(JobRepository); const { user } = await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 60 }).toJSDate() }); jobMock.queueAll.mockResolvedValue(void 0); - await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS); - expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([ - { name: JobName.USER_DELETION, data: { id: user.id } }, - ]); + await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.Success); + expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([{ name: JobName.UserDeletion, data: { id: user.id } }]); }); it('should skip a recently deleted user', async () => { @@ -160,7 +158,7 @@ describe(UserService.name, () => { const jobMock = ctx.getMock(JobRepository); await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 5 }).toJSDate() }); jobMock.queueAll.mockResolvedValue(void 0); - await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS); + await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.Success); expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]); }); @@ -172,7 +170,7 @@ describe(UserService.name, () => { const config = await sut.getConfig({ withCache: false }); config.user.deleteDelay = 30; await sut.updateConfig(config); - await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.SUCCESS); + await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.Success); expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([]); }); }); diff --git a/server/test/medium/specs/services/version.service.spec.ts b/server/test/medium/specs/services/version.service.spec.ts index 9feda5b8c4..2d0c57667b 100644 --- a/server/test/medium/specs/services/version.service.spec.ts +++ b/server/test/medium/specs/services/version.service.spec.ts @@ -53,7 +53,7 @@ describe(VersionService.name, () => { await versionHistoryRepo.create({ version: 'v1.128.0' }); await sut.onBootstrap(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MEMORIES_CREATE }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MemoriesCreate }); }); it('should not queue memory creation when upgrading from 1.129.0', async () => { diff --git a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts index 2c6b98e949..808a4785ce 100644 --- a/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset-exif.spec.ts @@ -25,7 +25,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { await ctx.newExif({ assetId: asset.id, make: 'Canon' }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]); expect(response).toHaveLength(1); @@ -86,7 +86,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { await ctx.newExif({ assetId: asset.id, make: 'Canon' }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.Editor }); const { session } = await ctx.newSession({ userId: user3.id }); const authUser3 = factory.auth({ session, user: user3 }); @@ -110,7 +110,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { await ctx.newExif({ assetId: asset3User2.id, make: 'asset3User2' }); const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id }); - await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]); expect(response).toHaveLength(1); @@ -134,7 +134,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => { ctx.newAlbumAsset({ albumId: album2.id, assetId }), ), ); - await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); // should backfill the album user const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]); diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 41700d29d4..9a42c0f027 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -41,7 +41,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); expect(response).toHaveLength(1); @@ -90,7 +90,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { const { asset } = await ctx.newAsset({ ownerId: user3.id }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: user3.id, role: AlbumUserRole.Editor }); const { session } = await ctx.newSession({ userId: user3.id }); const authUser3 = factory.auth({ session, user: user3 }); @@ -111,7 +111,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await wait(2); const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id }); - await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); expect(response).toHaveLength(1); @@ -135,7 +135,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { ctx.newAlbumAsset({ albumId: album2.id, assetId }), ), ); - await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); // should backfill the album user const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); diff --git a/server/test/medium/specs/sync/sync-album-to-asset.spec.ts b/server/test/medium/specs/sync/sync-album-to-asset.spec.ts index a0c1d413a0..ee529c5001 100644 --- a/server/test/medium/specs/sync/sync-album-to-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-to-asset.spec.ts @@ -73,7 +73,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => { const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { album } = await ctx.newAlbum({ ownerId: user2.id }); await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]); expect(response).toHaveLength(1); @@ -130,7 +130,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => { await ctx.syncAckAll(auth, response); // add user to backfill album - await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); // should backfill the album to asset relation const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]); diff --git a/server/test/medium/specs/sync/sync-album-user.spec.ts b/server/test/medium/specs/sync/sync-album-user.spec.ts index 798b3d607d..e3d8a21493 100644 --- a/server/test/medium/specs/sync/sync-album-user.spec.ts +++ b/server/test/medium/specs/sync/sync-album-user.spec.ts @@ -22,7 +22,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { const { auth, ctx } = await setup(); const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); const { user } = await ctx.newUser(); - const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR }); + const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor }); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([ { @@ -42,7 +42,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { const { auth, ctx } = await setup(); const { user: user1 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); - const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR }); + const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(response).toHaveLength(1); @@ -67,13 +67,13 @@ describe(SyncRequestType.AlbumUsersV1, () => { const albumUserRepo = ctx.get(AlbumUserRepository); const { user: user1 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); - const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR }); + const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); await ctx.syncAckAll(auth, response); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]); - await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.VIEWER }); + await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.Viewer }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(newResponse).toHaveLength(1); expect(newResponse).toEqual([ @@ -81,7 +81,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { ack: expect.any(String), data: expect.objectContaining({ albumId: albumUser.albumId, - role: AlbumUserRole.VIEWER, + role: AlbumUserRole.Viewer, userId: albumUser.userId, }), type: SyncEntityType.AlbumUserV1, @@ -97,7 +97,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { const albumUserRepo = ctx.get(AlbumUserRepository); const { user: user1 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: auth.user.id }); - const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.EDITOR }); + const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(response).toHaveLength(1); @@ -130,7 +130,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); @@ -157,8 +157,8 @@ describe(SyncRequestType.AlbumUsersV1, () => { const { user: owner } = await ctx.newUser(); const { user: user } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: owner.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); - await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); + await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(response).toHaveLength(2); @@ -166,14 +166,14 @@ describe(SyncRequestType.AlbumUsersV1, () => { await ctx.syncAckAll(auth, response); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]); - await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.VIEWER }); + await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.Viewer }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(newResponse).toEqual([ { ack: expect.any(String), data: expect.objectContaining({ albumId: album.id, - role: AlbumUserRole.VIEWER, + role: AlbumUserRole.Viewer, userId: user.id, }), type: SyncEntityType.AlbumUserV1, @@ -190,8 +190,8 @@ describe(SyncRequestType.AlbumUsersV1, () => { const { user: owner } = await ctx.newUser(); const { user: user } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: owner.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); - await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); + await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(response).toHaveLength(2); @@ -223,13 +223,13 @@ describe(SyncRequestType.AlbumUsersV1, () => { const { album: album1 } = await ctx.newAlbum({ ownerId: user1.id }); const { album: album2 } = await ctx.newAlbum({ ownerId: user1.id }); // backfill album user - await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album1.id, userId: user1.id, role: AlbumUserRole.Editor }); await wait(2); // initial album user - await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); await wait(2); // post checkpoint album user - await ctx.newAlbumUser({ albumId: album1.id, userId: user2.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album1.id, userId: user2.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); expect(response).toHaveLength(1); @@ -238,7 +238,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { ack: expect.any(String), data: expect.objectContaining({ albumId: album2.id, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, userId: auth.user.id, }), type: SyncEntityType.AlbumUserV1, @@ -248,7 +248,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { // ack initial user await ctx.syncAckAll(auth, response); // get access to the backfill album user - await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); // should backfill the album user const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]); @@ -257,7 +257,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { ack: expect.any(String), data: expect.objectContaining({ albumId: album1.id, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, userId: user1.id, }), type: SyncEntityType.AlbumUserBackfillV1, @@ -271,7 +271,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { ack: expect.any(String), data: expect.objectContaining({ albumId: album1.id, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, userId: user2.id, }), type: SyncEntityType.AlbumUserV1, @@ -280,7 +280,7 @@ describe(SyncRequestType.AlbumUsersV1, () => { ack: expect.any(String), data: expect.objectContaining({ albumId: album1.id, - role: AlbumUserRole.EDITOR, + role: AlbumUserRole.Editor, userId: auth.user.id, }), type: SyncEntityType.AlbumUserV1, diff --git a/server/test/medium/specs/sync/sync-album.spec.ts b/server/test/medium/specs/sync/sync-album.spec.ts index 83e9f12651..9f44e617e3 100644 --- a/server/test/medium/specs/sync/sync-album.spec.ts +++ b/server/test/medium/specs/sync/sync-album.spec.ts @@ -101,7 +101,7 @@ describe(SyncRequestType.AlbumsV1, () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: user2.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]); expect(response).toHaveLength(1); @@ -121,7 +121,7 @@ describe(SyncRequestType.AlbumsV1, () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: user2.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]); expect(response).toHaveLength(1); @@ -153,7 +153,7 @@ describe(SyncRequestType.AlbumsV1, () => { ]); await ctx.syncAckAll(auth, response); - await ctx.newAlbumUser({ userId: auth.user.id, albumId: user2Album.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ userId: auth.user.id, albumId: user2Album.id, role: AlbumUserRole.Editor }); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]); expect(newResponse).toHaveLength(1); @@ -174,7 +174,7 @@ describe(SyncRequestType.AlbumsV1, () => { const albumRepo = ctx.get(AlbumRepository); const { user: user2 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: user2.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]); expect(response).toHaveLength(1); @@ -202,7 +202,7 @@ describe(SyncRequestType.AlbumsV1, () => { const albumUserRepo = ctx.get(AlbumUserRepository); const { user: user2 } = await ctx.newUser(); const { album } = await ctx.newAlbum({ ownerId: user2.id }); - await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.EDITOR }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]); expect(response).toHaveLength(1); diff --git a/server/test/medium/specs/sync/sync-user-metadata.spec.ts b/server/test/medium/specs/sync/sync-user-metadata.spec.ts index bb4a500a60..7cd53e76e3 100644 --- a/server/test/medium/specs/sync/sync-user-metadata.spec.ts +++ b/server/test/medium/specs/sync/sync-user-metadata.spec.ts @@ -22,7 +22,7 @@ describe(SyncEntityType.UserMetadataV1, () => { const { auth, user, ctx } = await setup(); const userRepo = ctx.get(UserRepository); - await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.ONBOARDING, value: { isOnboarded: true } }); + await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: true } }); const response = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]); expect(response).toHaveLength(1); @@ -30,7 +30,7 @@ describe(SyncEntityType.UserMetadataV1, () => { { ack: expect.any(String), data: { - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, userId: user.id, value: { isOnboarded: true }, }, @@ -46,7 +46,7 @@ describe(SyncEntityType.UserMetadataV1, () => { const { auth, user, ctx } = await setup(); const userRepo = ctx.get(UserRepository); - await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.ONBOARDING, value: { isOnboarded: true } }); + await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: true } }); const response = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]); expect(response).toHaveLength(1); @@ -54,7 +54,7 @@ describe(SyncEntityType.UserMetadataV1, () => { { ack: expect.any(String), data: { - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, userId: user.id, value: { isOnboarded: true }, }, @@ -64,14 +64,14 @@ describe(SyncEntityType.UserMetadataV1, () => { await ctx.syncAckAll(auth, response); - await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.ONBOARDING, value: { isOnboarded: false } }); + await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: false } }); const updatedResponse = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]); expect(updatedResponse).toEqual([ { ack: expect.any(String), data: { - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, userId: user.id, value: { isOnboarded: false }, }, @@ -89,7 +89,7 @@ describe(SyncEntityType.UserMetadataDeleteV1, () => { const { auth, user, ctx } = await setup(); const userRepo = ctx.get(UserRepository); - await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.ONBOARDING, value: { isOnboarded: true } }); + await userRepo.upsertMetadata(user.id, { key: UserMetadataKey.Onboarding, value: { isOnboarded: true } }); const response = await ctx.syncStream(auth, [SyncRequestType.UserMetadataV1]); expect(response).toHaveLength(1); @@ -97,7 +97,7 @@ describe(SyncEntityType.UserMetadataDeleteV1, () => { { ack: expect.any(String), data: { - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, userId: user.id, value: { isOnboarded: true }, }, @@ -107,14 +107,14 @@ describe(SyncEntityType.UserMetadataDeleteV1, () => { await ctx.syncAckAll(auth, response); - await userRepo.deleteMetadata(auth.user.id, UserMetadataKey.ONBOARDING); + await userRepo.deleteMetadata(auth.user.id, UserMetadataKey.Onboarding); await expect(ctx.syncStream(auth, [SyncRequestType.UserMetadataV1])).resolves.toEqual([ { ack: expect.any(String), data: { userId: user.id, - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, }, type: 'UserMetadataDeleteV1', }, diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 460dfeef57..e31e1a3348 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -5,7 +5,7 @@ import { Mocked, vitest } from 'vitest'; const envData: EnvData = { port: 2283, - environment: ImmichEnvironment.PRODUCTION, + environment: ImmichEnvironment.Production, buildMetadata: {}, bull: { @@ -31,7 +31,7 @@ const envData: EnvData = { }, skipMigrations: false, - vectorExtension: DatabaseExtension.VECTORS, + vectorExtension: DatabaseExtension.Vectors, }, licensePublicKey: { @@ -84,7 +84,7 @@ const envData: EnvData = { metrics: new Set(), }, - workers: [ImmichWorker.API, ImmichWorker.MICROSERVICES], + workers: [ImmichWorker.Api, ImmichWorker.Microservices], noColor: false, }; @@ -93,7 +93,7 @@ export const mockEnvData = (config: Partial) => ({ ...envData, ...confi export const newConfigRepositoryMock = (): Mocked> => { return { getEnv: vitest.fn().mockReturnValue(mockEnvData({})), - getWorker: vitest.fn().mockReturnValue(ImmichWorker.API), + getWorker: vitest.fn().mockReturnValue(ImmichWorker.Api), isDev: vitest.fn().mockReturnValue(false), }; }; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 7b0ebeb86b..6929924c3e 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -88,7 +88,7 @@ const authSharedLinkFactory = (sharedLink: Partial = {}) => { const authApiKeyFactory = (apiKey: Partial = {}) => ({ id: newUuid(), - permissions: [Permission.ALL], + permissions: [Permission.All], ...apiKey, }); @@ -154,7 +154,7 @@ const userFactory = (user: Partial = {}) => ({ profileChangedAt: newDate(), metadata: [ { - key: UserMetadataKey.ONBOARDING, + key: UserMetadataKey.Onboarding, value: 'true', }, ] as UserMetadataItem[], @@ -178,7 +178,7 @@ const userAdminFactory = (user: Partial = {}) => { oauthId = '', quotaSizeInBytes = null, quotaUsageInBytes = 0, - status = UserStatus.ACTIVE, + status = UserStatus.Active, metadata = [], } = user; return { @@ -208,7 +208,7 @@ const assetFactory = (asset: Partial = {}) => ({ updatedAt: newDate(), deletedAt: null, updateId: newUuidV7(), - status: AssetStatus.ACTIVE, + status: AssetStatus.Active, checksum: newSha1(), deviceAssetId: '', deviceId: '', @@ -229,8 +229,8 @@ const assetFactory = (asset: Partial = {}) => ({ sidecarPath: null, stackId: null, thumbhash: null, - type: AssetType.IMAGE, - visibility: AssetVisibility.TIMELINE, + type: AssetType.Image, + visibility: AssetVisibility.Timeline, ...asset, }); @@ -258,7 +258,7 @@ const apiKeyFactory = (apiKey: Partial = {}) => ({ updatedAt: newDate(), updateId: newUuidV7(), name: 'Api Key', - permissions: [Permission.ALL], + permissions: [Permission.All], ...apiKey, }); @@ -284,7 +284,7 @@ const memoryFactory = (memory: Partial = {}) => ({ updateId: newUuidV7(), deletedAt: null, ownerId: newUuid(), - type: MemoryType.ON_THIS_DAY, + type: MemoryType.OnThisDay, data: { year: 2024 } as OnThisDayData, isSaved: false, memoryAt: newDate(), From bcb968e3d1d57d258df4d2dfd524f80dae90dc1f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 15 Jul 2025 18:39:00 -0400 Subject: [PATCH 02/45] refactor: job names (#19949) --- server/src/enum.ts | 123 ++++++++---------- server/src/repositories/job.repository.ts | 6 +- .../src/services/asset-media.service.spec.ts | 8 +- server/src/services/asset-media.service.ts | 10 +- server/src/services/asset.service.spec.ts | 24 ++-- server/src/services/asset.service.ts | 20 +-- server/src/services/audit.service.ts | 2 +- server/src/services/auth.service.ts | 2 +- server/src/services/backup.service.ts | 4 +- server/src/services/duplicate.service.spec.ts | 4 +- server/src/services/duplicate.service.ts | 10 +- server/src/services/job.service.spec.ts | 64 ++++----- server/src/services/job.service.ts | 64 ++++----- server/src/services/library.service.spec.ts | 10 +- server/src/services/library.service.ts | 34 ++--- server/src/services/media.service.spec.ts | 26 ++-- server/src/services/media.service.ts | 40 +++--- server/src/services/memory.service.ts | 4 +- server/src/services/metadata.service.spec.ts | 16 +-- server/src/services/metadata.service.ts | 22 ++-- .../src/services/notification.service.spec.ts | 4 +- server/src/services/notification.service.ts | 10 +- server/src/services/person.service.spec.ts | 20 +-- server/src/services/person.service.ts | 26 ++-- server/src/services/session.service.ts | 2 +- server/src/services/smart-info.service.ts | 4 +- server/src/services/storage.service.ts | 4 +- server/src/services/trash.service.spec.ts | 8 +- server/src/services/trash.service.ts | 10 +- .../src/services/user-admin.service.spec.ts | 2 +- server/src/services/user-admin.service.ts | 2 +- server/src/services/user.service.spec.ts | 6 +- server/src/services/user.service.ts | 12 +- server/src/services/version.service.ts | 2 +- server/src/types.ts | 68 +++++----- .../specs/services/user.service.spec.ts | 2 +- .../specs/services/version.service.spec.ts | 2 +- 37 files changed, 334 insertions(+), 343 deletions(-) diff --git a/server/src/enum.ts b/server/src/enum.ts index 1d8b8645d1..587a76126f 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -441,94 +441,75 @@ export enum QueueName { } export enum JobName { - //backups - BackupDatabase = 'database-backup', + AssetDelete = 'AssetDelete', + AssetDeleteCheck = 'AssetDeleteCheck', + AssetDetectFacesQueueAll = 'AssetDetectFacesQueueAll', + AssetDetectFaces = 'AssetDetectFaces', + AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll', + AssetDetectDuplicates = 'AssetDetectDuplicates', + AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll', + AssetEncodeVideo = 'AssetEncodeVideo', + AssetEmptyTrash = 'AssetEmptyTrash', + AssetExtractMetadataQueueAll = 'AssetExtractMetadataQueueAll', + AssetExtractMetadata = 'AssetExtractMetadata', + AssetFileMigration = 'AssetFileMigration', + AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll', + AssetGenerateThumbnails = 'AssetGenerateThumbnails', - // conversion - QueueVideoConversion = 'queue-video-conversion', - VideoConversation = 'video-conversion', + AuditLogCleanup = 'AuditLogCleanup', - // thumbnails - QueueGenerateThumbnails = 'queue-generate-thumbnails', - GenerateThumbnails = 'generate-thumbnails', - GeneratePersonThumbnail = 'generate-person-thumbnail', + DatabaseBackup = 'DatabaseBackup', - // metadata - QueueMetadataExtraction = 'queue-metadata-extraction', - MetadataExtraction = 'metadata-extraction', + FacialRecognitionQueueAll = 'FacialRecognitionQueueAll', + FacialRecognition = 'FacialRecognition', - // user - UserDeletion = 'user-deletion', - UserDeleteCheck = 'user-delete-check', - userSyncUsage = 'user-sync-usage', + FileDelete = 'FileDelete', + FileMigrationQueueAll = 'FileMigrationQueueAll', - // asset - AssetDeletion = 'asset-deletion', - AssetDeletionCheck = 'asset-deletion-check', + LibraryDeleteCheck = 'LibraryDeleteCheck', + LibraryDelete = 'LibraryDelete', + LibraryRemoveAsset = 'LibraryRemoveAsset', + LibrarySyncAssetsQueueAll = 'LibraryScanAssetsQueueAll', + LibrarySyncAssets = 'LibrarySyncAssets', + LibrarySyncFilesQueueAll = 'LibrarySyncFilesQueueAll', + LibrarySyncFiles = 'LibrarySyncFiles', + LibraryScanQueueAll = 'LibraryScanQueueAll', - // storage template - StorageTemplateMigration = 'storage-template-migration', - StorageTemplateMigrationSingle = 'storage-template-migration-single', + MemoryCleanup = 'MemoryCleanup', + MemoryGenerate = 'MemoryGenerate', - // tags - TagCleanup = 'tag-cleanup', + NotificationsCleanup = 'NotificationsCleanup', - // migration - QueueMigration = 'queue-migration', - MigrateAsset = 'migrate-asset', - MigratePerson = 'migrate-person', + NotifyUserSignup = 'NotifyUserSignup', + NotifyAlbumInvite = 'NotifyAlbumInvite', + NotifyAlbumUpdate = 'NotifyAlbumUpdate', - // facial recognition - PersonCleanup = 'person-cleanup', - QueueFaceDetection = 'queue-face-detection', - FaceDetection = 'face-detection', - QueueFacialRecognition = 'queue-facial-recognition', - FacialRecognition = 'facial-recognition', + UserDelete = 'UserDelete', + UserDeleteCheck = 'UserDeleteCheck', + UserSyncUsage = 'UserSyncUsage', - // library management - LibraryQueueSyncFiles = 'library-queue-sync-files', - LibraryQueueSyncAssets = 'library-queue-sync-assets', - LibrarySyncFiles = 'library-sync-files', - LibrarySyncAssets = 'library-sync-assets', - LibraryAssetRemoval = 'handle-library-file-deletion', - LibraryDelete = 'library-delete', - LibraryQueueScanAll = 'library-queue-scan-all', - LibraryQueueCleanup = 'library-queue-cleanup', + PersonCleanup = 'PersonCleanup', + PersonFileMigration = 'PersonFileMigration', + PersonGenerateThumbnail = 'PersonGenerateThumbnail', - // cleanup - DeleteFiles = 'delete-files', - CleanOldAuditLogs = 'clean-old-audit-logs', - CleanOldSessionTokens = 'clean-old-session-tokens', + SessionCleanup = 'SessionCleanup', - // memories - MemoriesCleanup = 'memories-cleanup', - MemoriesCreate = 'memories-create', + SendMail = 'SendMail', - // smart search - QueueSmartSearch = 'queue-smart-search', - SmartSearch = 'smart-search', + SidecarQueueAll = 'SidecarQueueAll', + SidecarDiscovery = 'SidecarDiscovery', + SidecarSync = 'SidecarSync', + SidecarWrite = 'SidecarWrite', - QueueTrashEmpty = 'queue-trash-empty', + SmartSearchQueueAll = 'SmartSearchQueueAll', + SmartSearch = 'SmartSearch', - // duplicate detection - QueueDuplicateDetection = 'queue-duplicate-detection', - DuplicateDetection = 'duplicate-detection', + StorageTemplateMigration = 'StorageTemplateMigration', + StorageTemplateMigrationSingle = 'StorageTemplateMigrationSingle', - // XMP sidecars - QueueSidecar = 'queue-sidecar', - SidecarDiscovery = 'sidecar-discovery', - SidecarSync = 'sidecar-sync', - SidecarWrite = 'sidecar-write', + TagCleanup = 'TagCleanup', - // Notification - NotifySignup = 'notify-signup', - NotifyAlbumInvite = 'notify-album-invite', - NotifyAlbumUpdate = 'notify-album-update', - NotificationsCleanup = 'notifications-cleanup', - SendMail = 'notification-send-email', - - // Version check - VersionCheck = 'version-check', + VersionCheck = 'VersionCheck', } export enum JobCommand { diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 3550529bfc..5acd8d5746 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -214,11 +214,11 @@ export class JobRepository { case JobName.StorageTemplateMigrationSingle: { return { jobId: item.data.id }; } - case JobName.GeneratePersonThumbnail: { + case JobName.PersonGenerateThumbnail: { return { priority: 1 }; } - case JobName.QueueFacialRecognition: { - return { jobId: JobName.QueueFacialRecognition }; + case JobName.FacialRecognitionQueueAll: { + return { jobId: JobName.FacialRecognitionQueueAll }; } default: { return null; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index c881ebb497..dccb79c585 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -384,7 +384,7 @@ describe(AssetMediaService.name, () => { }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); @@ -409,7 +409,7 @@ describe(AssetMediaService.name, () => { ); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: ['fake_path/asset_1.jpeg', undefined] }, }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); @@ -815,7 +815,7 @@ describe(AssetMediaService.name, () => { expect(mocks.asset.create).not.toHaveBeenCalled(); expect(mocks.asset.updateAll).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: [updatedFile.originalPath, undefined] }, }); expect(mocks.user.updateUsage).not.toHaveBeenCalled(); @@ -912,7 +912,7 @@ describe(AssetMediaService.name, () => { await sut.onUploadError(request, file); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, }); }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 127fdbe8eb..080774d038 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -121,7 +121,7 @@ export class AssetMediaService extends BaseService { const uploadFolder = this.getUploadFolder(asRequest(request, file)); const uploadPath = `${uploadFolder}/${uploadFilename}`; - await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [uploadPath] } }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } }); } async uploadAsset( @@ -312,7 +312,7 @@ export class AssetMediaService extends BaseService { ): Promise { // clean up files await this.jobRepository.queue({ - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: [file.originalPath, sidecarFile?.originalPath] }, }); @@ -365,7 +365,7 @@ export class AssetMediaService extends BaseService { await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }); await this.jobRepository.queue({ - name: JobName.MetadataExtraction, + name: JobName.AssetExtractMetadata, data: { id: assetId, source: 'upload' }, }); } @@ -394,7 +394,7 @@ export class AssetMediaService extends BaseService { const { size } = await this.storageRepository.stat(created.originalPath); await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size }); - await this.jobRepository.queue({ name: JobName.MetadataExtraction, data: { id: created.id, source: 'copy' } }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } }); return created; } @@ -427,7 +427,7 @@ export class AssetMediaService extends BaseService { } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); - await this.jobRepository.queue({ name: JobName.MetadataExtraction, data: { id: asset.id, source: 'upload' } }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); return asset; } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 606c93eab8..6461735976 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -522,7 +522,7 @@ describe(AssetService.name, () => { expect(mocks.assetJob.streamForDeletedJob).toHaveBeenCalledWith(new Date()); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetDeletion, data: { id: asset.id, deleteOnDisk: true } }, + { name: JobName.AssetDelete, data: { id: asset.id, deleteOnDisk: true } }, ]); }); @@ -536,7 +536,7 @@ describe(AssetService.name, () => { expect(mocks.assetJob.streamForDeletedJob).toHaveBeenCalledWith(DateTime.now().minus({ days: 7 }).toJSDate()); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetDeletion, data: { id: asset.id, deleteOnDisk: true } }, + { name: JobName.AssetDelete, data: { id: asset.id, deleteOnDisk: true } }, ]); }); }); @@ -552,7 +552,7 @@ describe(AssetService.name, () => { expect(mocks.job.queue.mock.calls).toEqual([ [ { - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: [ '/uploads/user-id/webp/path.ext', @@ -606,7 +606,7 @@ describe(AssetService.name, () => { expect(mocks.job.queue.mock.calls).toEqual([ [ { - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id: assetStub.livePhotoMotionAsset.id, deleteOnDisk: true, @@ -615,7 +615,7 @@ describe(AssetService.name, () => { ], [ { - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: [ '/uploads/user-id/webp/path.ext', @@ -643,7 +643,7 @@ describe(AssetService.name, () => { expect(mocks.job.queue.mock.calls).toEqual([ [ { - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: [ '/uploads/user-id/webp/path.ext', @@ -679,7 +679,7 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_FACES }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.FaceDetection, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetDetectFaces, data: { id: 'asset-1' } }]); }); it('should run the refresh metadata job', async () => { @@ -687,7 +687,9 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REFRESH_METADATA }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.MetadataExtraction, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetExtractMetadata, data: { id: 'asset-1' } }, + ]); }); it('should run the refresh thumbnails job', async () => { @@ -695,7 +697,9 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.REGENERATE_THUMBNAIL }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.GenerateThumbnails, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, + ]); }); it('should run the transcode video', async () => { @@ -703,7 +707,7 @@ describe(AssetService.name, () => { await sut.run(authStub.admin, { assetIds: ['asset-1'], name: AssetJobName.TRANSCODE_VIDEO }); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.VideoConversation, data: { id: 'asset-1' } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetEncodeVideo, data: { id: 'asset-1' } }]); }); }); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index d9b0527d5b..864a9cc512 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -145,7 +145,7 @@ export class AssetService extends BaseService { } } - @OnJob({ name: JobName.AssetDeletionCheck, queue: QueueName.BackgroundTask }) + @OnJob({ name: JobName.AssetDeleteCheck, queue: QueueName.BackgroundTask }) async handleAssetDeletionCheck(): Promise { const config = await this.getConfig({ withCache: false }); const trashedDays = config.trash.enabled ? config.trash.days : 0; @@ -158,7 +158,7 @@ export class AssetService extends BaseService { if (chunk.length > 0) { await this.jobRepository.queueAll( chunk.map(({ id, isOffline }) => ({ - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id, deleteOnDisk: !isOffline }, })), ); @@ -179,8 +179,8 @@ export class AssetService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.AssetDeletion, queue: QueueName.BackgroundTask }) - async handleAssetDeletion(job: JobOf): Promise { + @OnJob({ name: JobName.AssetDelete, queue: QueueName.BackgroundTask }) + async handleAssetDeletion(job: JobOf): Promise { const { id, deleteOnDisk } = job; const asset = await this.assetJobRepository.getForAssetDeletion(id); @@ -215,7 +215,7 @@ export class AssetService extends BaseService { const count = await this.assetRepository.getLivePhotoCount(asset.livePhotoVideoId); if (count === 0) { await this.jobRepository.queue({ - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id: asset.livePhotoVideoId, deleteOnDisk }, }); } @@ -228,7 +228,7 @@ export class AssetService extends BaseService { files.push(asset.sidecarPath, asset.originalPath); } - await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files } }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files } }); return JobStatus.Success; } @@ -255,22 +255,22 @@ export class AssetService extends BaseService { for (const id of dto.assetIds) { switch (dto.name) { case AssetJobName.REFRESH_FACES: { - jobs.push({ name: JobName.FaceDetection, data: { id } }); + jobs.push({ name: JobName.AssetDetectFaces, data: { id } }); break; } case AssetJobName.REFRESH_METADATA: { - jobs.push({ name: JobName.MetadataExtraction, data: { id } }); + jobs.push({ name: JobName.AssetExtractMetadata, data: { id } }); break; } case AssetJobName.REGENERATE_THUMBNAIL: { - jobs.push({ name: JobName.GenerateThumbnails, data: { id } }); + jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id } }); break; } case AssetJobName.TRANSCODE_VIDEO: { - jobs.push({ name: JobName.VideoConversation, data: { id } }); + jobs.push({ name: JobName.AssetEncodeVideo, data: { id } }); break; } } diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index eaa48de1f4..498d99b82c 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -7,7 +7,7 @@ import { BaseService } from 'src/services/base.service'; @Injectable() export class AuditService extends BaseService { - @OnJob({ name: JobName.CleanOldAuditLogs, queue: QueueName.BackgroundTask }) + @OnJob({ name: JobName.AuditLogCleanup, queue: QueueName.BackgroundTask }) async handleCleanup(): Promise { await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); return JobStatus.Success; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 00b7c1e9aa..a5b0de25cd 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -330,7 +330,7 @@ export class AuthService extends BaseService { await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() }); if (oldPath) { - await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [oldPath] } }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } }); } } catch (error: Error | any) { this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts index a5882b4f80..9daa2c3aea 100644 --- a/server/src/services/backup.service.ts +++ b/server/src/services/backup.service.ts @@ -26,7 +26,7 @@ export class BackupService extends BaseService { this.cronRepository.create({ name: 'backupDatabase', expression: database.cronExpression, - onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.BackupDatabase }), this.logger), + onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.DatabaseBackup }), this.logger), start: database.enabled, }); } @@ -68,7 +68,7 @@ export class BackupService extends BaseService { this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`); } - @OnJob({ name: JobName.BackupDatabase, queue: QueueName.BackupDatabase }) + @OnJob({ name: JobName.DatabaseBackup, queue: QueueName.BackupDatabase }) async handleBackupDatabase(): Promise { this.logger.debug(`Database Backup Started`); const { database } = this.configRepository.getEnv(); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 0a2243e322..e5ac9f82ba 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -108,7 +108,7 @@ describe(SearchService.name, () => { expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.DuplicateDetection, + name: JobName.AssetDetectDuplicates, data: { id: assetStub.image.id }, }, ]); @@ -122,7 +122,7 @@ describe(SearchService.name, () => { expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.DuplicateDetection, + name: JobName.AssetDetectDuplicates, data: { id: assetStub.image.id }, }, ]); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 1699cf73f6..618754ff74 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -29,8 +29,8 @@ export class DuplicateService extends BaseService { await this.duplicateRepository.deleteAll(auth.user.id, dto.ids); } - @OnJob({ name: JobName.QueueDuplicateDetection, queue: QueueName.DuplicateDetection }) - async handleQueueSearchDuplicates({ force }: JobOf): Promise { + @OnJob({ name: JobName.AssetDetectDuplicatesQueueAll, queue: QueueName.DuplicateDetection }) + async handleQueueSearchDuplicates({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.Skipped; @@ -44,7 +44,7 @@ export class DuplicateService extends BaseService { const assets = this.assetJobRepository.streamForSearchDuplicates(force); for await (const asset of assets) { - jobs.push({ name: JobName.DuplicateDetection, data: { id: asset.id } }); + jobs.push({ name: JobName.AssetDetectDuplicates, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -55,8 +55,8 @@ export class DuplicateService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.DuplicateDetection, queue: QueueName.DuplicateDetection }) - async handleSearchDuplicates({ id }: JobOf): Promise { + @OnJob({ name: JobName.AssetDetectDuplicates, queue: QueueName.DuplicateDetection }) + async handleSearchDuplicates({ id }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: true }); if (!isDuplicateDetectionEnabled(machineLearning)) { return JobStatus.Skipped; diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 497d81b4ca..a57db736af 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -37,16 +37,16 @@ describe(JobService.name, () => { await sut.handleNightlyJobs(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.AssetDeletionCheck }, + { name: JobName.AssetDeleteCheck }, { name: JobName.UserDeleteCheck }, { name: JobName.PersonCleanup }, - { name: JobName.MemoriesCleanup }, - { name: JobName.CleanOldSessionTokens }, - { name: JobName.CleanOldAuditLogs }, - { name: JobName.MemoriesCreate }, - { name: JobName.userSyncUsage }, - { name: JobName.QueueGenerateThumbnails, data: { force: false } }, - { name: JobName.QueueFacialRecognition, data: { force: false, nightly: true } }, + { name: JobName.MemoryCleanup }, + { name: JobName.SessionCleanup }, + { name: JobName.AuditLogCleanup }, + { name: JobName.MemoryGenerate }, + { name: JobName.UserSyncUsage }, + { name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }, + { name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }, ]); }); }); @@ -136,7 +136,7 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.VideoConversion, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueVideoConversion, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEncodeVideoQueueAll, data: { force: false } }); }); it('should handle a start storage template migration command', async () => { @@ -152,7 +152,7 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.SmartSearch, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueSmartSearch, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SmartSearchQueueAll, data: { force: false } }); }); it('should handle a start metadata extraction command', async () => { @@ -160,7 +160,10 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.MetadataExtraction, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueMetadataExtraction, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.AssetExtractMetadataQueueAll, + data: { force: false }, + }); }); it('should handle a start sidecar command', async () => { @@ -168,7 +171,7 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.Sidecar, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueSidecar, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.SidecarQueueAll, data: { force: false } }); }); it('should handle a start thumbnail generation command', async () => { @@ -176,7 +179,10 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.ThumbnailGeneration, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueGenerateThumbnails, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ + name: JobName.AssetGenerateThumbnailsQueueAll, + data: { force: false }, + }); }); it('should handle a start face detection command', async () => { @@ -184,7 +190,7 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.FaceDetection, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueFaceDetection, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetDetectFacesQueueAll, data: { force: false } }); }); it('should handle a start facial recognition command', async () => { @@ -192,7 +198,7 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.FacialRecognition, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueFacialRecognition, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FacialRecognitionQueueAll, data: { force: false } }); }); it('should handle a start backup database command', async () => { @@ -200,7 +206,7 @@ describe(JobService.name, () => { await sut.handleCommand(QueueName.BackupDatabase, { command: JobCommand.Start, force: false }); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.BackupDatabase, data: { force: false } }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.DatabaseBackup, data: { force: false } }); }); it('should throw a bad request when an invalid queue is used', async () => { @@ -220,55 +226,55 @@ describe(JobService.name, () => { mocks.job.run.mockResolvedValue(JobStatus.Success); await sut.onJobStart(QueueName.BackgroundTask, { - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: ['path/to/file'] }, }); expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1); expect(mocks.telemetry.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1); - expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1); + expect(mocks.telemetry.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.file_delete.success', 1); expect(mocks.logger.error).not.toHaveBeenCalled(); }); const tests: Array<{ item: JobItem; jobs: JobName[]; stub?: any }> = [ { item: { name: JobName.SidecarSync, data: { id: 'asset-1' } }, - jobs: [JobName.MetadataExtraction], + jobs: [JobName.AssetExtractMetadata], }, { item: { name: JobName.SidecarDiscovery, data: { id: 'asset-1' } }, - jobs: [JobName.MetadataExtraction], + jobs: [JobName.AssetExtractMetadata], }, { item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.GenerateThumbnails], + jobs: [JobName.AssetGenerateThumbnails], }, { item: { name: JobName.StorageTemplateMigrationSingle, data: { id: 'asset-1' } }, jobs: [], }, { - item: { name: JobName.GeneratePersonThumbnail, data: { id: 'asset-1' } }, + item: { name: JobName.PersonGenerateThumbnail, data: { id: 'asset-1' } }, jobs: [], }, { - item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1' } }, + item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], stub: [assetStub.image], }, { - item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1' } }, + item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, jobs: [], stub: [assetStub.video], }, { - item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.SmartSearch, JobName.FaceDetection], + item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SmartSearch, JobName.AssetDetectFaces], stub: [assetStub.livePhotoStillAsset], }, { - item: { name: JobName.GenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, - jobs: [JobName.SmartSearch, JobName.FaceDetection, JobName.VideoConversation], + item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, + jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.AssetEncodeVideo], stub: [assetStub.video], }, { @@ -276,7 +282,7 @@ describe(JobService.name, () => { jobs: [], }, { - item: { name: JobName.FaceDetection, data: { id: 'asset-1' } }, + item: { name: JobName.AssetDetectFaces, data: { id: 'asset-1' } }, jobs: [], }, { diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 73254b9f38..c67f3af39f 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -40,15 +40,15 @@ const asJobItem = (dto: JobCreateDto): JobItem => { } case ManualJobName.MemoryCleanup: { - return { name: JobName.MemoriesCleanup }; + return { name: JobName.MemoryCleanup }; } case ManualJobName.MemoryCreate: { - return { name: JobName.MemoriesCreate }; + return { name: JobName.MemoryGenerate }; } case ManualJobName.BackupDatabase: { - return { name: JobName.BackupDatabase }; + return { name: JobName.DatabaseBackup }; } default: { @@ -190,7 +190,7 @@ export class JobService extends BaseService { switch (name) { case QueueName.VideoConversion: { - return this.jobRepository.queue({ name: JobName.QueueVideoConversion, data: { force } }); + return this.jobRepository.queue({ name: JobName.AssetEncodeVideoQueueAll, data: { force } }); } case QueueName.StorageTemplateMigration: { @@ -198,43 +198,43 @@ export class JobService extends BaseService { } case QueueName.Migration: { - return this.jobRepository.queue({ name: JobName.QueueMigration }); + return this.jobRepository.queue({ name: JobName.FileMigrationQueueAll }); } case QueueName.SmartSearch: { - return this.jobRepository.queue({ name: JobName.QueueSmartSearch, data: { force } }); + return this.jobRepository.queue({ name: JobName.SmartSearchQueueAll, data: { force } }); } case QueueName.DuplicateDetection: { - return this.jobRepository.queue({ name: JobName.QueueDuplicateDetection, data: { force } }); + return this.jobRepository.queue({ name: JobName.AssetDetectDuplicatesQueueAll, data: { force } }); } case QueueName.MetadataExtraction: { - return this.jobRepository.queue({ name: JobName.QueueMetadataExtraction, data: { force } }); + return this.jobRepository.queue({ name: JobName.AssetExtractMetadataQueueAll, data: { force } }); } case QueueName.Sidecar: { - return this.jobRepository.queue({ name: JobName.QueueSidecar, data: { force } }); + return this.jobRepository.queue({ name: JobName.SidecarQueueAll, data: { force } }); } case QueueName.ThumbnailGeneration: { - return this.jobRepository.queue({ name: JobName.QueueGenerateThumbnails, data: { force } }); + return this.jobRepository.queue({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force } }); } case QueueName.FaceDetection: { - return this.jobRepository.queue({ name: JobName.QueueFaceDetection, data: { force } }); + return this.jobRepository.queue({ name: JobName.AssetDetectFacesQueueAll, data: { force } }); } case QueueName.FacialRecognition: { - return this.jobRepository.queue({ name: JobName.QueueFacialRecognition, data: { force } }); + return this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force } }); } case QueueName.Library: { - return this.jobRepository.queue({ name: JobName.LibraryQueueScanAll, data: { force } }); + return this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: { force } }); } case QueueName.BackupDatabase: { - return this.jobRepository.queue({ name: JobName.BackupDatabase, data: { force } }); + return this.jobRepository.queue({ name: JobName.DatabaseBackup, data: { force } }); } default: { @@ -249,7 +249,7 @@ export class JobService extends BaseService { this.telemetryRepository.jobs.addToGauge(queueMetric, 1); try { const status = await this.jobRepository.run(job); - const jobMetric = `immich.jobs.${job.name.replaceAll('-', '_')}.${status}`; + const jobMetric = `immich.jobs.${snakeCase(job.name)}.${status}`; this.telemetryRepository.jobs.addToCounter(jobMetric, 1); if (status === JobStatus.Success || status == JobStatus.Skipped) { await this.onDone(job); @@ -276,29 +276,29 @@ export class JobService extends BaseService { if (config.nightlyTasks.databaseCleanup) { jobs.push( - { name: JobName.AssetDeletionCheck }, + { name: JobName.AssetDeleteCheck }, { name: JobName.UserDeleteCheck }, { name: JobName.PersonCleanup }, - { name: JobName.MemoriesCleanup }, - { name: JobName.CleanOldSessionTokens }, - { name: JobName.CleanOldAuditLogs }, + { name: JobName.MemoryCleanup }, + { name: JobName.SessionCleanup }, + { name: JobName.AuditLogCleanup }, ); } if (config.nightlyTasks.generateMemories) { - jobs.push({ name: JobName.MemoriesCreate }); + jobs.push({ name: JobName.MemoryGenerate }); } if (config.nightlyTasks.syncQuotaUsage) { - jobs.push({ name: JobName.userSyncUsage }); + jobs.push({ name: JobName.UserSyncUsage }); } if (config.nightlyTasks.missingThumbnails) { - jobs.push({ name: JobName.QueueGenerateThumbnails, data: { force: false } }); + jobs.push({ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } }); } if (config.nightlyTasks.clusterNewFaces) { - jobs.push({ name: JobName.QueueFacialRecognition, data: { force: false, nightly: true } }); + jobs.push({ name: JobName.FacialRecognitionQueueAll, data: { force: false, nightly: true } }); } await this.jobRepository.queueAll(jobs); @@ -311,13 +311,13 @@ export class JobService extends BaseService { switch (item.name) { case JobName.SidecarSync: case JobName.SidecarDiscovery: { - await this.jobRepository.queue({ name: JobName.MetadataExtraction, data: item.data }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: item.data }); break; } case JobName.SidecarWrite: { await this.jobRepository.queue({ - name: JobName.MetadataExtraction, + name: JobName.AssetExtractMetadata, data: { id: item.data.id, source: 'sidecar-write' }, }); break; @@ -325,12 +325,12 @@ export class JobService extends BaseService { case JobName.StorageTemplateMigrationSingle: { if (item.data.source === 'upload' || item.data.source === 'copy') { - await this.jobRepository.queue({ name: JobName.GenerateThumbnails, data: item.data }); + await this.jobRepository.queue({ name: JobName.AssetGenerateThumbnails, data: item.data }); } break; } - case JobName.GeneratePersonThumbnail: { + case JobName.PersonGenerateThumbnail: { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { @@ -339,7 +339,7 @@ export class JobService extends BaseService { break; } - case JobName.GenerateThumbnails: { + case JobName.AssetGenerateThumbnails: { if (!item.data.notify && item.data.source !== 'upload') { break; } @@ -352,11 +352,11 @@ export class JobService extends BaseService { const jobs: JobItem[] = [ { name: JobName.SmartSearch, data: item.data }, - { name: JobName.FaceDetection, data: item.data }, + { name: JobName.AssetDetectFaces, data: item.data }, ]; if (asset.type === AssetType.Video) { - jobs.push({ name: JobName.VideoConversation, data: item.data }); + jobs.push({ name: JobName.AssetEncodeVideo, data: item.data }); } await this.jobRepository.queueAll(jobs); @@ -419,12 +419,12 @@ export class JobService extends BaseService { case JobName.SmartSearch: { if (item.data.source === 'upload') { - await this.jobRepository.queue({ name: JobName.DuplicateDetection, data: item.data }); + await this.jobRepository.queue({ name: JobName.AssetDetectDuplicates, data: item.data }); } break; } - case JobName.UserDeletion: { + case JobName.UserDelete: { this.eventRepository.clientBroadcast('on_user_delete', item.data.id); break; } diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index aa2b80d928..dac2b64ebc 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1010,7 +1010,7 @@ describe(LibraryService.name, () => { await sut.watchAll(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LibraryAssetRemoval, + name: JobName.LibraryRemoveAsset, data: { libraryId: library.id, paths: [assetStub.image.originalPath], @@ -1131,11 +1131,11 @@ describe(LibraryService.name, () => { expect(mocks.job.queue).toHaveBeenCalledTimes(2); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LibraryQueueSyncFiles, + name: JobName.LibrarySyncFilesQueueAll, data: { id: library.id }, }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LibraryQueueSyncAssets, + name: JobName.LibrarySyncAssetsQueueAll, data: { id: library.id }, }); }); @@ -1150,11 +1150,11 @@ describe(LibraryService.name, () => { await expect(sut.handleQueueScanAll()).resolves.toBe(JobStatus.Success); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.LibraryQueueCleanup, + name: JobName.LibraryDeleteCheck, data: {}, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.LibraryQueueSyncFiles, data: { id: library.id } }, + { name: JobName.LibrarySyncFilesQueueAll, data: { id: library.id } }, ]); }); }); diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index f8377081fa..4c96ad0062 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -47,7 +47,7 @@ export class LibraryService extends BaseService { this.cronRepository.create({ name: CronJob.LibraryScan, expression: scan.cronExpression, - onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.LibraryQueueScanAll }), this.logger), + onTick: () => handlePromiseError(this.jobRepository.queue({ name: JobName.LibraryScanQueueAll }), this.logger), start: scan.enabled, }); } @@ -113,7 +113,7 @@ export class LibraryService extends BaseService { const deletionHandler = async (path: string) => { this.logger.debug(`File unlink event received for ${path} in library ${library.id}}`); await this.jobRepository.queue({ - name: JobName.LibraryAssetRemoval, + name: JobName.LibraryRemoveAsset, data: { libraryId: library.id, paths: [path] }, }); }; @@ -198,7 +198,7 @@ export class LibraryService extends BaseService { return libraries.map((library) => mapLibrary(library)); } - @OnJob({ name: JobName.LibraryQueueCleanup, queue: QueueName.Library }) + @OnJob({ name: JobName.LibraryDeleteCheck, queue: QueueName.Library }) async handleQueueCleanup(): Promise { this.logger.log('Checking for any libraries pending deletion...'); const pendingDeletions = await this.libraryRepository.getAllDeleted(); @@ -355,7 +355,7 @@ export class LibraryService extends BaseService { assetsFound = true; this.logger.debug(`Queueing deletion of ${chunk.length} asset(s) in library ${libraryId}`); await this.jobRepository.queueAll( - chunk.map((id) => ({ name: JobName.AssetDeletion, data: { id, deleteOnDisk: false } })), + chunk.map((id) => ({ name: JobName.AssetDelete, data: { id, deleteOnDisk: false } })), ); chunk = []; } @@ -422,30 +422,30 @@ export class LibraryService extends BaseService { this.logger.log(`Starting to scan library ${id}`); await this.jobRepository.queue({ - name: JobName.LibraryQueueSyncFiles, + name: JobName.LibrarySyncFilesQueueAll, data: { id, }, }); - await this.jobRepository.queue({ name: JobName.LibraryQueueSyncAssets, data: { id } }); + await this.jobRepository.queue({ name: JobName.LibrarySyncAssetsQueueAll, data: { id } }); } async queueScanAll() { - await this.jobRepository.queue({ name: JobName.LibraryQueueScanAll, data: {} }); + await this.jobRepository.queue({ name: JobName.LibraryScanQueueAll, data: {} }); } - @OnJob({ name: JobName.LibraryQueueScanAll, queue: QueueName.Library }) + @OnJob({ name: JobName.LibraryScanQueueAll, queue: QueueName.Library }) async handleQueueScanAll(): Promise { this.logger.log(`Initiating scan of all external libraries...`); - await this.jobRepository.queue({ name: JobName.LibraryQueueCleanup, data: {} }); + await this.jobRepository.queue({ name: JobName.LibraryDeleteCheck, data: {} }); const libraries = await this.libraryRepository.getAll(true); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LibraryQueueSyncFiles, + name: JobName.LibrarySyncFilesQueueAll, data: { id: library.id, }, @@ -453,7 +453,7 @@ export class LibraryService extends BaseService { ); await this.jobRepository.queueAll( libraries.map((library) => ({ - name: JobName.LibraryQueueSyncAssets, + name: JobName.LibrarySyncAssetsQueueAll, data: { id: library.id, }, @@ -598,8 +598,8 @@ export class LibraryService extends BaseService { return AssetSyncResult.DO_NOTHING; } - @OnJob({ name: JobName.LibraryQueueSyncFiles, queue: QueueName.Library }) - async handleQueueSyncFiles(job: JobOf): Promise { + @OnJob({ name: JobName.LibrarySyncFilesQueueAll, queue: QueueName.Library }) + async handleQueueSyncFiles(job: JobOf): Promise { const library = await this.libraryRepository.get(job.id); if (!library) { this.logger.debug(`Library ${job.id} not found, skipping refresh`); @@ -668,8 +668,8 @@ export class LibraryService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.LibraryAssetRemoval, queue: QueueName.Library }) - async handleAssetRemoval(job: JobOf): Promise { + @OnJob({ name: JobName.LibraryRemoveAsset, queue: QueueName.Library }) + async handleAssetRemoval(job: JobOf): Promise { // This is only for handling file unlink events via the file watcher this.logger.verbose(`Deleting asset(s) ${job.paths} from library ${job.libraryId}`); for (const assetPath of job.paths) { @@ -682,8 +682,8 @@ export class LibraryService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.LibraryQueueSyncAssets, queue: QueueName.Library }) - async handleQueueSyncAssets(job: JobOf): Promise { + @OnJob({ name: JobName.LibrarySyncAssetsQueueAll, queue: QueueName.Library }) + async handleQueueSyncAssets(job: JobOf): Promise { const library = await this.libraryRepository.get(job.id); if (!library) { return JobStatus.Skipped; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index f535bb65ff..d3c361bb8a 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -49,7 +49,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -57,7 +57,7 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: personStub.newThumbnail.id }, }, ]); @@ -72,7 +72,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: assetStub.trashed.id }, }, ]); @@ -87,7 +87,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: assetStub.archived.id }, }, ]); @@ -106,7 +106,7 @@ describe(MediaService.name, () => { expect(mocks.person.update).toHaveBeenCalledTimes(1); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: personStub.newThumbnail.id, }, @@ -122,7 +122,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -138,7 +138,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -154,7 +154,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: assetStub.image.id }, }, ]); @@ -173,10 +173,10 @@ describe(MediaService.name, () => { expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.MigrateAsset, data: { id: assetStub.image.id } }, + { name: JobName.AssetFileMigration, data: { id: assetStub.image.id } }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.MigratePerson, data: { id: personStub.withName.id } }, + { name: JobName.PersonFileMigration, data: { id: personStub.withName.id } }, ]); }); }); @@ -1243,7 +1243,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.VideoConversation, + name: JobName.AssetEncodeVideo, data: { id: assetStub.video.id }, }, ]); @@ -1257,7 +1257,7 @@ describe(MediaService.name, () => { expect(mocks.assetJob.streamForVideoConversion).toHaveBeenCalledWith(void 0); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.VideoConversation, + name: JobName.AssetEncodeVideo, data: { id: assetStub.video.id }, }, ]); @@ -1616,7 +1616,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.DeleteFiles, + name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] }, }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index b48df3797a..a2c3c5ed42 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -57,8 +57,8 @@ export class MediaService extends BaseService { this.videoInterfaces = { dri, mali }; } - @OnJob({ name: JobName.QueueGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) - async handleQueueGenerateThumbnails({ force }: JobOf): Promise { + @OnJob({ name: JobName.AssetGenerateThumbnailsQueueAll, queue: QueueName.ThumbnailGeneration }) + async handleQueueGenerateThumbnails({ force }: JobOf): Promise { let jobs: JobItem[] = []; const queueAll = async () => { @@ -70,7 +70,7 @@ export class MediaService extends BaseService { const { previewFile, thumbnailFile } = getAssetFiles(asset.files); if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { - jobs.push({ name: JobName.GenerateThumbnails, data: { id: asset.id } }); + jobs.push({ name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }); } if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { @@ -92,7 +92,7 @@ export class MediaService extends BaseService { await this.personRepository.update({ id: person.id, faceAssetId: face.id }); } - jobs.push({ name: JobName.GeneratePersonThumbnail, data: { id: person.id } }); + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } @@ -103,7 +103,7 @@ export class MediaService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.QueueMigration, queue: QueueName.Migration }) + @OnJob({ name: JobName.FileMigrationQueueAll, queue: QueueName.Migration }) async handleQueueMigration(): Promise { const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.Migration); if (active === 1 && waiting === 0) { @@ -114,7 +114,7 @@ export class MediaService extends BaseService { let jobs: JobItem[] = []; const assets = this.assetJobRepository.streamForMigrationJob(); for await (const asset of assets) { - jobs.push({ name: JobName.MigrateAsset, data: { id: asset.id } }); + jobs.push({ name: JobName.AssetFileMigration, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); jobs = []; @@ -125,7 +125,7 @@ export class MediaService extends BaseService { jobs = []; for await (const person of this.personRepository.getAll()) { - jobs.push({ name: JobName.MigratePerson, data: { id: person.id } }); + jobs.push({ name: JobName.PersonFileMigration, data: { id: person.id } }); if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); @@ -138,8 +138,8 @@ export class MediaService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.MigrateAsset, queue: QueueName.Migration }) - async handleAssetMigration({ id }: JobOf): Promise { + @OnJob({ name: JobName.AssetFileMigration, queue: QueueName.Migration }) + async handleAssetMigration({ id }: JobOf): Promise { const { image } = await this.getConfig({ withCache: true }); const asset = await this.assetJobRepository.getForMigrationJob(id); if (!asset) { @@ -154,8 +154,8 @@ export class MediaService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.GenerateThumbnails, queue: QueueName.ThumbnailGeneration }) - async handleGenerateThumbnails({ id }: JobOf): Promise { + @OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration }) + async handleGenerateThumbnails({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id); if (!asset) { this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); @@ -317,8 +317,8 @@ export class MediaService extends BaseService { return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; } - @OnJob({ name: JobName.GeneratePersonThumbnail, queue: QueueName.ThumbnailGeneration }) - async handleGeneratePersonThumbnail({ id }: JobOf): Promise { + @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) + async handleGeneratePersonThumbnail({ id }: JobOf): Promise { const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { return JobStatus.Skipped; @@ -443,13 +443,13 @@ export class MediaService extends BaseService { return { previewPath, thumbnailPath, thumbhash }; } - @OnJob({ name: JobName.QueueVideoConversion, queue: QueueName.VideoConversion }) - async handleQueueVideoConversion(job: JobOf): Promise { + @OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion }) + async handleQueueVideoConversion(job: JobOf): Promise { const { force } = job; - let queue: { name: JobName.VideoConversation; data: { id: string } }[] = []; + let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = []; for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) { - queue.push({ name: JobName.VideoConversation, data: { id: asset.id } }); + queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } }); if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(queue); @@ -462,8 +462,8 @@ export class MediaService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.VideoConversation, queue: QueueName.VideoConversion }) - async handleVideoConversion({ id }: JobOf): Promise { + @OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion }) + async handleVideoConversion({ id }: JobOf): Promise { const asset = await this.assetJobRepository.getForVideoConversion(id); if (!asset) { return JobStatus.Failed; @@ -492,7 +492,7 @@ export class MediaService extends BaseService { if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); - await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [asset.encodedVideoPath] } }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); } else { this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index ff88b9029c..7bf9deab4b 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -12,7 +12,7 @@ const DAYS = 3; @Injectable() export class MemoryService extends BaseService { - @OnJob({ name: JobName.MemoriesCreate, queue: QueueName.BackgroundTask }) + @OnJob({ name: JobName.MemoryGenerate, queue: QueueName.BackgroundTask }) async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); const usersIds = await Promise.all( @@ -72,7 +72,7 @@ export class MemoryService extends BaseService { ); } - @OnJob({ name: JobName.MemoriesCleanup, queue: QueueName.BackgroundTask }) + @OnJob({ name: JobName.MemoryCleanup, queue: QueueName.BackgroundTask }) async onMemoriesCleanup() { await this.memoryRepository.cleanup(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 23884256ea..77f0b50a0a 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -106,7 +106,7 @@ describe(MetadataService.name, () => { expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(false); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.MetadataExtraction, + name: JobName.AssetExtractMetadata, data: { id: assetStub.image.id }, }, ]); @@ -119,7 +119,7 @@ describe(MetadataService.name, () => { expect(mocks.assetJob.streamForMetadataExtraction).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.MetadataExtraction, + name: JobName.AssetExtractMetadata, data: { id: assetStub.image.id }, }, ]); @@ -599,7 +599,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ - name: JobName.VideoConversation, + name: JobName.AssetEncodeVideo, data: { id: assetStub.livePhotoMotionAsset.id }, }); }); @@ -657,7 +657,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ - name: JobName.VideoConversation, + name: JobName.AssetEncodeVideo, data: { id: assetStub.livePhotoMotionAsset.id }, }); }); @@ -715,7 +715,7 @@ describe(MetadataService.name, () => { }); expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ - name: JobName.VideoConversation, + name: JobName.AssetEncodeVideo, data: { id: assetStub.livePhotoMotionAsset.id }, }); }); @@ -737,7 +737,7 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, }); }); @@ -1116,7 +1116,7 @@ describe(MetadataService.name, () => { ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: personStub.withName.id }, }, ]); @@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => { ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: personStub.withName.id }, }, ]); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 037bab3633..32a3d98f4e 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -193,13 +193,13 @@ export class MetadataService extends BaseService { await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId }); } - @OnJob({ name: JobName.QueueMetadataExtraction, queue: QueueName.MetadataExtraction }) - async handleQueueMetadataExtraction(job: JobOf): Promise { + @OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction }) + async handleQueueMetadataExtraction(job: JobOf): Promise { const { force } = job; - let queue: { name: JobName.MetadataExtraction; data: { id: string } }[] = []; + let queue: { name: JobName.AssetExtractMetadata; data: { id: string } }[] = []; for await (const asset of this.assetJobRepository.streamForMetadataExtraction(force)) { - queue.push({ name: JobName.MetadataExtraction, data: { id: asset.id } }); + queue.push({ name: JobName.AssetExtractMetadata, data: { id: asset.id } }); if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(queue); @@ -211,8 +211,8 @@ export class MetadataService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.MetadataExtraction, queue: QueueName.MetadataExtraction }) - async handleMetadataExtraction(data: JobOf) { + @OnJob({ name: JobName.AssetExtractMetadata, queue: QueueName.MetadataExtraction }) + async handleMetadataExtraction(data: JobOf) { const [{ metadata, reverseGeocoding }, asset] = await Promise.all([ this.getConfig({ withCache: true }), this.assetJobRepository.getForMetadataExtraction(data.id), @@ -320,8 +320,8 @@ export class MetadataService extends BaseService { }); } - @OnJob({ name: JobName.QueueSidecar, queue: QueueName.Sidecar }) - async handleQueueSidecar({ force }: JobOf): Promise { + @OnJob({ name: JobName.SidecarQueueAll, queue: QueueName.Sidecar }) + async handleQueueSidecar({ force }: JobOf): Promise { let jobs: JobItem[] = []; const queueAll = async () => { await this.jobRepository.queueAll(jobs); @@ -597,7 +597,7 @@ export class MetadataService extends BaseService { // note asset.livePhotoVideoId is not motionAsset.id yet if (asset.livePhotoVideoId) { await this.jobRepository.queue({ - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id: asset.livePhotoVideoId, deleteOnDisk: true }, }); this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`); @@ -612,7 +612,7 @@ export class MetadataService extends BaseService { this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`); await this.handleMetadataExtraction({ id: motionAsset.id }); - await this.jobRepository.queue({ name: JobName.VideoConversation, data: { id: motionAsset.id } }); + await this.jobRepository.queue({ name: JobName.AssetEncodeVideo, data: { id: motionAsset.id } }); } this.logger.debug(`Finished motion photo video extraction for asset ${asset.id}: ${asset.originalPath}`); @@ -753,7 +753,7 @@ export class MetadataService extends BaseService { if (missing.length > 0) { this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); const newPersonIds = await this.personRepository.createAll(missing); - const jobs = newPersonIds.map((id) => ({ name: JobName.GeneratePersonThumbnail, data: { id } }) as const); + const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const); await this.jobRepository.queueAll(jobs); } diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 97e5ddf106..eef1c4f8b2 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -131,7 +131,7 @@ describe(NotificationService.name, () => { it('should queue the generate thumbnail job', async () => { await sut.onAssetShow({ assetId: 'asset-id', userId: 'user-id' }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.GenerateThumbnails, + name: JobName.AssetGenerateThumbnails, data: { id: 'asset-id', notify: true }, }); }); @@ -146,7 +146,7 @@ describe(NotificationService.name, () => { it('should queue notify signup event if notify is true', async () => { await sut.onUserSignup({ id: '', notify: true }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.NotifySignup, + name: JobName.NotifyUserSignup, data: { id: '', tempPassword: undefined }, }); }); diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 90a3b63ac5..1a257309b2 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -87,7 +87,7 @@ export class NotificationService extends BaseService { this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); switch (job.name) { - case JobName.BackupDatabase: { + case JobName.DatabaseBackup: { const errorMessage = error instanceof Error ? error.message : error; const item = await this.notificationRepository.create({ userId: admin.id, @@ -135,7 +135,7 @@ export class NotificationService extends BaseService { @OnEvent({ name: 'AssetShow' }) async onAssetShow({ assetId }: ArgOf<'AssetShow'>) { - await this.jobRepository.queue({ name: JobName.GenerateThumbnails, data: { id: assetId, notify: true } }); + await this.jobRepository.queue({ name: JobName.AssetGenerateThumbnails, data: { id: assetId, notify: true } }); } @OnEvent({ name: 'AssetTrash' }) @@ -193,7 +193,7 @@ export class NotificationService extends BaseService { @OnEvent({ name: 'UserSignup' }) async onUserSignup({ notify, id, tempPassword }: ArgOf<'UserSignup'>) { if (notify) { - await this.jobRepository.queue({ name: JobName.NotifySignup, data: { id, tempPassword } }); + await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, tempPassword } }); } } @@ -313,8 +313,8 @@ export class NotificationService extends BaseService { return { name, html: templateResponse }; } - @OnJob({ name: JobName.NotifySignup, queue: QueueName.Notification }) - async handleUserSignup({ id, tempPassword }: JobOf) { + @OnJob({ name: JobName.NotifyUserSignup, queue: QueueName.Notification }) + async handleUserSignup({ id, tempPassword }: JobOf) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { return JobStatus.Skipped; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 8fbf8b20dd..13c3128317 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -276,7 +276,7 @@ describe(PersonService.name, () => { }, ]); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: 'person-1' }, }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); @@ -337,7 +337,7 @@ describe(PersonService.name, () => { expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: personStub.newThumbnail.id }, }, ]); @@ -373,7 +373,7 @@ describe(PersonService.name, () => { await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.GeneratePersonThumbnail, + name: JobName.PersonGenerateThumbnail, data: { id: personStub.newThumbnail.id }, }, ]); @@ -462,7 +462,7 @@ describe(PersonService.name, () => { expect(mocks.person.vacuum).not.toHaveBeenCalled(); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FaceDetection, + name: JobName.AssetDetectFaces, data: { id: assetStub.image.id }, }, ]); @@ -481,7 +481,7 @@ describe(PersonService.name, () => { expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FaceDetection, + name: JobName.AssetDetectFaces, data: { id: assetStub.image.id }, }, ]); @@ -499,7 +499,7 @@ describe(PersonService.name, () => { expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FaceDetection, + name: JobName.AssetDetectFaces, data: { id: assetStub.image.id }, }, ]); @@ -518,7 +518,7 @@ describe(PersonService.name, () => { expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.FaceDetection, + name: JobName.AssetDetectFaces, data: { id: assetStub.image.id }, }, ]); @@ -754,7 +754,7 @@ describe(PersonService.name, () => { expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.QueueFacialRecognition, data: { force: false } }, + { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognition, data: { id: faceId } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); @@ -790,7 +790,7 @@ describe(PersonService.name, () => { expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [faceStub.primaryFace1.id], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.QueueFacialRecognition, data: { force: false } }, + { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognition, data: { id: faceId } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); @@ -830,7 +830,7 @@ describe(PersonService.name, () => { expect(mocks.person.refreshFaces).toHaveBeenCalledWith([face], [], [faceSearch]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { name: JobName.QueueFacialRecognition, data: { force: false } }, + { name: JobName.FacialRecognitionQueueAll, data: { force: false } }, { name: JobName.FacialRecognition, data: { id: faceId } }, ]); expect(mocks.person.reassignFace).not.toHaveBeenCalled(); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index d8925578d4..d0c43c3dad 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -140,7 +140,7 @@ export class PersonService extends BaseService { if (assetFace) { await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); - jobs.push({ name: JobName.GeneratePersonThumbnail, data: { id: personId } }); + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } }); } } @@ -211,7 +211,7 @@ export class PersonService extends BaseService { }); if (assetId) { - await this.jobRepository.queue({ name: JobName.GeneratePersonThumbnail, data: { id } }); + await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } }); } return mapPerson(person); @@ -261,8 +261,8 @@ export class PersonService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.QueueFaceDetection, queue: QueueName.FaceDetection }) - async handleQueueDetectFaces({ force }: JobOf): Promise { + @OnJob({ name: JobName.AssetDetectFacesQueueAll, queue: QueueName.FaceDetection }) + async handleQueueDetectFaces({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.Skipped; @@ -277,7 +277,7 @@ export class PersonService extends BaseService { let jobs: JobItem[] = []; const assets = this.assetJobRepository.streamForDetectFacesJob(force); for await (const asset of assets) { - jobs.push({ name: JobName.FaceDetection, data: { id: asset.id } }); + jobs.push({ name: JobName.AssetDetectFaces, data: { id: asset.id } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await this.jobRepository.queueAll(jobs); @@ -294,8 +294,8 @@ export class PersonService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.FaceDetection, queue: QueueName.FaceDetection }) - async handleDetectFaces({ id }: JobOf): Promise { + @OnJob({ name: JobName.AssetDetectFaces, queue: QueueName.FaceDetection }) + async handleDetectFaces({ id }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: true }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.Skipped; @@ -369,7 +369,7 @@ export class PersonService extends BaseService { if (facesToAdd.length > 0) { this.logger.log(`Detected ${facesToAdd.length} new faces in asset ${id}`); const jobs = facesToAdd.map((face) => ({ name: JobName.FacialRecognition, data: { id: face.id } }) as const); - await this.jobRepository.queueAll([{ name: JobName.QueueFacialRecognition, data: { force: false } }, ...jobs]); + await this.jobRepository.queueAll([{ name: JobName.FacialRecognitionQueueAll, data: { force: false } }, ...jobs]); } else if (embeddings.length > 0) { this.logger.log(`Added ${embeddings.length} face embeddings for asset ${id}`); } @@ -396,8 +396,8 @@ export class PersonService extends BaseService { return intersection / union; } - @OnJob({ name: JobName.QueueFacialRecognition, queue: QueueName.FacialRecognition }) - async handleQueueRecognizeFaces({ force, nightly }: JobOf): Promise { + @OnJob({ name: JobName.FacialRecognitionQueueAll, queue: QueueName.FacialRecognition }) + async handleQueueRecognizeFaces({ force, nightly }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isFacialRecognitionEnabled(machineLearning)) { return JobStatus.Skipped; @@ -526,7 +526,7 @@ export class PersonService extends BaseService { if (isCore && !personId) { this.logger.log(`Creating new person for face ${id}`); const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); - await this.jobRepository.queue({ name: JobName.GeneratePersonThumbnail, data: { id: newPerson.id } }); + await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); personId = newPerson.id; } @@ -538,8 +538,8 @@ export class PersonService extends BaseService { return JobStatus.Success; } - @OnJob({ name: JobName.MigratePerson, queue: QueueName.Migration }) - async handlePersonMigration({ id }: JobOf): Promise { + @OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration }) + async handlePersonMigration({ id }: JobOf): Promise { const person = await this.personRepository.getById(id); if (!person) { return JobStatus.Failed; diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index b7bb5cb6fa..a9c7e92fcb 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -14,7 +14,7 @@ import { BaseService } from 'src/services/base.service'; @Injectable() export class SessionService extends BaseService { - @OnJob({ name: JobName.CleanOldSessionTokens, queue: QueueName.BackgroundTask }) + @OnJob({ name: JobName.SessionCleanup, queue: QueueName.BackgroundTask }) async handleCleanup(): Promise { const sessions = await this.sessionRepository.cleanup(); for (const session of sessions) { diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 5ce47593f4..3b8e2d1fc3 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -64,8 +64,8 @@ export class SmartInfoService extends BaseService { }); } - @OnJob({ name: JobName.QueueSmartSearch, queue: QueueName.SmartSearch }) - async handleQueueEncodeClip({ force }: JobOf): Promise { + @OnJob({ name: JobName.SmartSearchQueueAll, queue: QueueName.SmartSearch }) + async handleQueueEncodeClip({ force }: JobOf): Promise { const { machineLearning } = await this.getConfig({ withCache: false }); if (!isSmartSearchEnabled(machineLearning)) { return JobStatus.Skipped; diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index d8804665da..6e3ef4820a 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -62,8 +62,8 @@ export class StorageService extends BaseService { }); } - @OnJob({ name: JobName.DeleteFiles, queue: QueueName.BackgroundTask }) - async handleDeleteFiles(job: JobOf): Promise { + @OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask }) + async handleDeleteFiles(job: JobOf): Promise { const { files } = job; // TODO: one job per file diff --git a/server/src/services/trash.service.spec.ts b/server/src/services/trash.service.spec.ts index f742ce8757..e43c49e543 100644 --- a/server/src/services/trash.service.spec.ts +++ b/server/src/services/trash.service.spec.ts @@ -77,24 +77,24 @@ describe(TrashService.name, () => { mocks.trash.empty.mockResolvedValue(1); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueTrashEmpty, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEmptyTrash, data: {} }); }); }); describe('onAssetsDelete', () => { it('should queue the empty trash job', async () => { await expect(sut.onAssetsDelete()).resolves.toBeUndefined(); - expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.QueueTrashEmpty, data: {} }); + expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.AssetEmptyTrash, data: {} }); }); }); describe('handleQueueEmptyTrash', () => { it('should queue asset delete jobs', async () => { mocks.trash.getDeletedIds.mockReturnValue(makeAssetIdStream(1)); - await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.Success); + await expect(sut.handleEmptyTrash()).resolves.toEqual(JobStatus.Success); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id: 'asset-1', deleteOnDisk: true }, }, ]); diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 858feacd39..f1d368111e 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -35,18 +35,18 @@ export class TrashService extends BaseService { async empty(auth: AuthDto): Promise { const count = await this.trashRepository.empty(auth.user.id); if (count > 0) { - await this.jobRepository.queue({ name: JobName.QueueTrashEmpty, data: {} }); + await this.jobRepository.queue({ name: JobName.AssetEmptyTrash, data: {} }); } return { count }; } @OnEvent({ name: 'AssetDeleteAll' }) async onAssetsDelete() { - await this.jobRepository.queue({ name: JobName.QueueTrashEmpty, data: {} }); + await this.jobRepository.queue({ name: JobName.AssetEmptyTrash, data: {} }); } - @OnJob({ name: JobName.QueueTrashEmpty, queue: QueueName.BackgroundTask }) - async handleQueueEmptyTrash() { + @OnJob({ name: JobName.AssetEmptyTrash, queue: QueueName.BackgroundTask }) + async handleEmptyTrash() { const assets = this.trashRepository.getDeletedIds(); let count = 0; @@ -74,7 +74,7 @@ export class TrashService extends BaseService { this.logger.debug(`Queueing ${ids.length} asset(s) for deletion from the trash`); await this.jobRepository.queueAll( ids.map((assetId) => ({ - name: JobName.AssetDeletion, + name: JobName.AssetDelete, data: { id: assetId, deleteOnDisk: true, diff --git a/server/src/services/user-admin.service.spec.ts b/server/src/services/user-admin.service.spec.ts index 8854ade8e3..d8e13fcfbd 100644 --- a/server/src/services/user-admin.service.spec.ts +++ b/server/src/services/user-admin.service.spec.ts @@ -158,7 +158,7 @@ describe(UserAdminService.name, () => { deletedAt: expect.any(Date), }); expect(mocks.job.queue).toHaveBeenCalledWith({ - name: JobName.UserDeletion, + name: JobName.UserDelete, data: { id: userStub.user1.id, force: true }, }); }); diff --git a/server/src/services/user-admin.service.ts b/server/src/services/user-admin.service.ts index 91e21b6bb5..3ae9d429eb 100644 --- a/server/src/services/user-admin.service.ts +++ b/server/src/services/user-admin.service.ts @@ -104,7 +104,7 @@ export class UserAdminService extends BaseService { const user = await this.userRepository.update(id, { status, deletedAt: new Date() }); if (force) { - await this.jobRepository.queue({ name: JobName.UserDeletion, data: { id: user.id, force } }); + await this.jobRepository.queue({ name: JobName.UserDelete, data: { id: user.id, force } }); } return mapUserAdmin(user); diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 6ae6bc85b6..3a85389ace 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -122,7 +122,7 @@ describe(UserService.name, () => { await sut.createProfileImage(authStub.admin, file); - expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DeleteFiles, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files } }]]); }); it('should not delete the profile image if it has not been set', async () => { @@ -156,7 +156,7 @@ describe(UserService.name, () => { await sut.deleteProfileImage(authStub.admin); - expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.DeleteFiles, data: { files } }]]); + expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files } }]]); }); }); @@ -211,7 +211,7 @@ describe(UserService.name, () => { await sut.handleUserDeleteCheck(); expect(mocks.user.getDeletedAfter).toHaveBeenCalled(); - expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.UserDeletion, data: { id: user.id } }]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.UserDelete, data: { id: user.id } }]); }); }); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 4a65e4ab9f..6849b17ac3 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -99,7 +99,7 @@ export class UserService extends BaseService { }); if (oldpath !== '') { - await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [oldpath] } }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldpath] } }); } return { @@ -115,7 +115,7 @@ export class UserService extends BaseService { throw new BadRequestException("Can't delete a missing profile Image"); } await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() }); - await this.jobRepository.queue({ name: JobName.DeleteFiles, data: { files: [user.profileImagePath] } }); + await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [user.profileImagePath] } }); } async getProfileImage(id: string): Promise { @@ -213,7 +213,7 @@ export class UserService extends BaseService { }; } - @OnJob({ name: JobName.userSyncUsage, queue: QueueName.BackgroundTask }) + @OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask }) async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); return JobStatus.Success; @@ -223,12 +223,12 @@ export class UserService extends BaseService { async handleUserDeleteCheck(): Promise { const config = await this.getConfig({ withCache: false }); const users = await this.userRepository.getDeletedAfter(DateTime.now().minus({ days: config.user.deleteDelay })); - await this.jobRepository.queueAll(users.map((user) => ({ name: JobName.UserDeletion, data: { id: user.id } }))); + await this.jobRepository.queueAll(users.map((user) => ({ name: JobName.UserDelete, data: { id: user.id } }))); return JobStatus.Success; } - @OnJob({ name: JobName.UserDeletion, queue: QueueName.BackgroundTask }) - async handleUserDelete({ id, force }: JobOf): Promise { + @OnJob({ name: JobName.UserDelete, queue: QueueName.BackgroundTask }) + async handleUserDelete({ id, force }: JobOf): Promise { const config = await this.getConfig({ withCache: false }); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts index 5d2f87c2bf..c4d7e9974d 100644 --- a/server/src/services/version.service.ts +++ b/server/src/services/version.service.ts @@ -41,7 +41,7 @@ export class VersionService extends BaseService { const needsNewMemories = semver.lt(previousVersion, '1.129.0'); if (needsNewMemories) { - await this.jobRepository.queue({ name: JobName.MemoriesCreate }); + await this.jobRepository.queue({ name: JobName.MemoryGenerate }); } } }); diff --git a/server/src/types.ts b/server/src/types.ts index 6873613c30..79aeedd47d 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -273,93 +273,93 @@ export interface QueueStatus { export type JobItem = // Backups - | { name: JobName.BackupDatabase; data?: IBaseJob } + | { name: JobName.DatabaseBackup; data?: IBaseJob } // Transcoding - | { name: JobName.QueueVideoConversion; data: IBaseJob } - | { name: JobName.VideoConversation; data: IEntityJob } + | { name: JobName.AssetEncodeVideoQueueAll; data: IBaseJob } + | { name: JobName.AssetEncodeVideo; data: IEntityJob } // Thumbnails - | { name: JobName.QueueGenerateThumbnails; data: IBaseJob } - | { name: JobName.GenerateThumbnails; data: IEntityJob } + | { name: JobName.AssetGenerateThumbnailsQueueAll; data: IBaseJob } + | { name: JobName.AssetGenerateThumbnails; data: IEntityJob } // User | { name: JobName.UserDeleteCheck; data?: IBaseJob } - | { name: JobName.UserDeletion; data: IEntityJob } - | { name: JobName.userSyncUsage; data?: IBaseJob } + | { name: JobName.UserDelete; data: IEntityJob } + | { name: JobName.UserSyncUsage; data?: IBaseJob } // Storage Template | { name: JobName.StorageTemplateMigration; data?: IBaseJob } | { name: JobName.StorageTemplateMigrationSingle; data: IEntityJob } // Migration - | { name: JobName.QueueMigration; data?: IBaseJob } - | { name: JobName.MigrateAsset; data: IEntityJob } - | { name: JobName.MigratePerson; data: IEntityJob } + | { name: JobName.FileMigrationQueueAll; data?: IBaseJob } + | { name: JobName.AssetFileMigration; data: IEntityJob } + | { name: JobName.PersonFileMigration; data: IEntityJob } // Metadata Extraction - | { name: JobName.QueueMetadataExtraction; data: IBaseJob } - | { name: JobName.MetadataExtraction; data: IEntityJob } + | { name: JobName.AssetExtractMetadataQueueAll; data: IBaseJob } + | { name: JobName.AssetExtractMetadata; data: IEntityJob } // Notifications | { name: JobName.NotificationsCleanup; data?: IBaseJob } // Sidecar Scanning - | { name: JobName.QueueSidecar; data: IBaseJob } + | { name: JobName.SidecarQueueAll; data: IBaseJob } | { name: JobName.SidecarDiscovery; data: IEntityJob } | { name: JobName.SidecarSync; data: IEntityJob } | { name: JobName.SidecarWrite; data: ISidecarWriteJob } // Facial Recognition - | { name: JobName.QueueFaceDetection; data: IBaseJob } - | { name: JobName.FaceDetection; data: IEntityJob } - | { name: JobName.QueueFacialRecognition; data: INightlyJob } + | { name: JobName.AssetDetectFacesQueueAll; data: IBaseJob } + | { name: JobName.AssetDetectFaces; data: IEntityJob } + | { name: JobName.FacialRecognitionQueueAll; data: INightlyJob } | { name: JobName.FacialRecognition; data: IDeferrableJob } - | { name: JobName.GeneratePersonThumbnail; data: IEntityJob } + | { name: JobName.PersonGenerateThumbnail; data: IEntityJob } // Smart Search - | { name: JobName.QueueSmartSearch; data: IBaseJob } + | { name: JobName.SmartSearchQueueAll; data: IBaseJob } | { name: JobName.SmartSearch; data: IEntityJob } - | { name: JobName.QueueTrashEmpty; data?: IBaseJob } + | { name: JobName.AssetEmptyTrash; data?: IBaseJob } // Duplicate Detection - | { name: JobName.QueueDuplicateDetection; data: IBaseJob } - | { name: JobName.DuplicateDetection; data: IEntityJob } + | { name: JobName.AssetDetectDuplicatesQueueAll; data: IBaseJob } + | { name: JobName.AssetDetectDuplicates; data: IEntityJob } // Memories - | { name: JobName.MemoriesCleanup; data?: IBaseJob } - | { name: JobName.MemoriesCreate; data?: IBaseJob } + | { name: JobName.MemoryCleanup; data?: IBaseJob } + | { name: JobName.MemoryGenerate; data?: IBaseJob } // Filesystem - | { name: JobName.DeleteFiles; data: IDeleteFilesJob } + | { name: JobName.FileDelete; data: IDeleteFilesJob } // Cleanup - | { name: JobName.CleanOldAuditLogs; data?: IBaseJob } - | { name: JobName.CleanOldSessionTokens; data?: IBaseJob } + | { name: JobName.AuditLogCleanup; data?: IBaseJob } + | { name: JobName.SessionCleanup; data?: IBaseJob } // Tags | { name: JobName.TagCleanup; data?: IBaseJob } // Asset Deletion | { name: JobName.PersonCleanup; data?: IBaseJob } - | { name: JobName.AssetDeletion; data: IAssetDeleteJob } - | { name: JobName.AssetDeletionCheck; data?: IBaseJob } + | { name: JobName.AssetDelete; data: IAssetDeleteJob } + | { name: JobName.AssetDeleteCheck; data?: IBaseJob } // Library Management | { name: JobName.LibrarySyncFiles; data: ILibraryFileJob } - | { name: JobName.LibraryQueueSyncFiles; data: IEntityJob } - | { name: JobName.LibraryQueueSyncAssets; data: IEntityJob } + | { name: JobName.LibrarySyncFilesQueueAll; data: IEntityJob } + | { name: JobName.LibrarySyncAssetsQueueAll; data: IEntityJob } | { name: JobName.LibrarySyncAssets; data: ILibraryBulkIdsJob } - | { name: JobName.LibraryAssetRemoval; data: ILibraryFileJob } + | { name: JobName.LibraryRemoveAsset; data: ILibraryFileJob } | { name: JobName.LibraryDelete; data: IEntityJob } - | { name: JobName.LibraryQueueScanAll; data?: IBaseJob } - | { name: JobName.LibraryQueueCleanup; data: IBaseJob } + | { name: JobName.LibraryScanQueueAll; data?: IBaseJob } + | { name: JobName.LibraryDeleteCheck; data: IBaseJob } // Notification | { name: JobName.SendMail; data: IEmailJob } | { name: JobName.NotifyAlbumInvite; data: INotifyAlbumInviteJob } | { name: JobName.NotifyAlbumUpdate; data: INotifyAlbumUpdateJob } - | { name: JobName.NotifySignup; data: INotifySignupJob } + | { name: JobName.NotifyUserSignup; data: INotifySignupJob } // Version check | { name: JobName.VersionCheck; data: IBaseJob }; diff --git a/server/test/medium/specs/services/user.service.spec.ts b/server/test/medium/specs/services/user.service.spec.ts index f5bddca196..7643be292e 100644 --- a/server/test/medium/specs/services/user.service.spec.ts +++ b/server/test/medium/specs/services/user.service.spec.ts @@ -150,7 +150,7 @@ describe(UserService.name, () => { const { user } = await ctx.newUser({ deletedAt: DateTime.now().minus({ days: 60 }).toJSDate() }); jobMock.queueAll.mockResolvedValue(void 0); await expect(sut.handleUserDeleteCheck()).resolves.toEqual(JobStatus.Success); - expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([{ name: JobName.UserDeletion, data: { id: user.id } }]); + expect(jobMock.queueAll).toHaveBeenCalledExactlyOnceWith([{ name: JobName.UserDelete, data: { id: user.id } }]); }); it('should skip a recently deleted user', async () => { diff --git a/server/test/medium/specs/services/version.service.spec.ts b/server/test/medium/specs/services/version.service.spec.ts index 2d0c57667b..3e81429382 100644 --- a/server/test/medium/specs/services/version.service.spec.ts +++ b/server/test/medium/specs/services/version.service.spec.ts @@ -53,7 +53,7 @@ describe(VersionService.name, () => { await versionHistoryRepo.create({ version: 'v1.128.0' }); await sut.onBootstrap(); - expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MemoriesCreate }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.MemoryGenerate }); }); it('should not queue memory creation when upgrading from 1.129.0', async () => { From 34620e1e9ae214b9d9c2392f6a4bde99c18771b5 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 15 Jul 2025 20:37:44 -0500 Subject: [PATCH 03/45] feat: album edit (#19936) --- i18n/en.json | 1 + .../lib/domain/models/album/album.model.dart | 28 + .../domain/services/remote_album.service.dart | 72 ++ .../lib/extensions/datetime_extensions.dart | 56 ++ .../repositories/remote_album.repository.dart | 164 +++++ .../repositories/timeline.repository.dart | 101 ++- .../lib/pages/album/album_control_button.dart | 50 +- mobile/lib/pages/album/album_viewer.dart | 16 +- .../presentation/pages/drift_album.page.dart | 27 +- .../pages/drift_create_album.page.dart | 2 + .../pages/drift_remote_album.page.dart | 422 ++++++++++- .../pages/drift_user_selection.page.dart | 215 ++++++ .../drift_album_option.widget.dart | 118 +++ .../current_album.provider.dart | 39 + .../infrastructure/remote_album.provider.dart | 107 ++- .../drift_album_api_repository.dart | 71 ++ mobile/lib/routing/router.dart | 6 + mobile/lib/routing/router.gr.dart | 38 + .../album/remote_album_shared_user_icons.dart | 50 ++ .../common/remote_album_sliver_app_bar.dart | 687 ++++++++++++++++++ .../widgets/common/user_circle_avatar.dart | 54 +- .../extensions/datetime_extensions_test.dart | 49 ++ 22 files changed, 2271 insertions(+), 102 deletions(-) create mode 100644 mobile/lib/presentation/pages/drift_user_selection.page.dart create mode 100644 mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart create mode 100644 mobile/lib/providers/infrastructure/current_album.provider.dart create mode 100644 mobile/lib/widgets/album/remote_album_shared_user_icons.dart create mode 100644 mobile/lib/widgets/common/remote_album_sliver_app_bar.dart create mode 100644 mobile/test/modules/extensions/datetime_extensions_test.dart diff --git a/i18n/en.json b/i18n/en.json index f547a4e48d..d2acfcc58f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1938,6 +1938,7 @@ "user_usage_stats_description": "View account usage statistics", "username": "Username", "users": "Users", + "users_added_to_album_count": "Added {count, plural, one {# user} other {# users}} to the album", "utilities": "Utilities", "validate": "Validate", "validate_endpoint_error": "Please enter a valid URL", diff --git a/mobile/lib/domain/models/album/album.model.dart b/mobile/lib/domain/models/album/album.model.dart index 7cafca9116..a199bce129 100644 --- a/mobile/lib/domain/models/album/album.model.dart +++ b/mobile/lib/domain/models/album/album.model.dart @@ -86,4 +86,32 @@ class RemoteAlbum { assetCount.hashCode ^ ownerName.hashCode; } + + RemoteAlbum copyWith({ + String? id, + String? name, + String? ownerId, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + int? assetCount, + String? ownerName, + }) { + return RemoteAlbum( + id: id ?? this.id, + name: name ?? this.name, + ownerId: ownerId ?? this.ownerId, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + assetCount: assetCount ?? this.assetCount, + ownerName: ownerName ?? this.ownerName, + ); + } } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index ae9e8b5336..ebb24d5fe5 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; @@ -10,6 +14,10 @@ class RemoteAlbumService { const RemoteAlbumService(this._repository, this._albumApiRepository); + Stream watchAlbum(String albumId) { + return _repository.watchAlbum(albumId); + } + Future> getAll() { return _repository.getAll(); } @@ -75,4 +83,68 @@ class RemoteAlbumService { return album; } + + Future updateAlbum( + String albumId, { + String? name, + String? description, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + }) async { + final updatedAlbum = await _albumApiRepository.updateAlbum( + albumId, + name: name, + description: description, + thumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order, + ); + + // Update the local database + await _repository.update(updatedAlbum); + + return updatedAlbum; + } + + FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { + return _repository.getDateRange(albumId); + } + + Future> getSharedUsers(String albumId) { + return _repository.getSharedUsers(albumId); + } + + Future> getAssets(String albumId) { + return _repository.getAssets(albumId); + } + + Future addAssets({ + required String albumId, + required List assetIds, + }) async { + final album = await _albumApiRepository.addAssets( + albumId, + assetIds, + ); + + await _repository.addAssets(albumId, album.added); + + return album.added.length; + } + + Future deleteAlbum(String albumId) async { + await _albumApiRepository.deleteAlbum(albumId); + + await _repository.deleteAlbum(albumId); + } + + Future addUsers({ + required String albumId, + required List userIds, + }) async { + await _albumApiRepository.addUsers(albumId, userIds); + + return _repository.addUsers(albumId, userIds); + } } diff --git a/mobile/lib/extensions/datetime_extensions.dart b/mobile/lib/extensions/datetime_extensions.dart index 14d89e2755..e23bf5210f 100644 --- a/mobile/lib/extensions/datetime_extensions.dart +++ b/mobile/lib/extensions/datetime_extensions.dart @@ -1,3 +1,6 @@ +import 'dart:ui'; +import 'package:easy_localization/easy_localization.dart'; + extension TimeAgoExtension on DateTime { /// Displays the time difference of this [DateTime] object to the current time as a [String] String timeAgo({bool numericDates = true}) { @@ -35,3 +38,56 @@ extension TimeAgoExtension on DateTime { return '${(difference.inDays / 365).floor()} years ago'; } } + +/// Extension to format date ranges according to UI requirements +extension DateRangeFormatting on DateTime { + /// Formats a date range according to specific rules: + /// - Single date of this year: "Aug 28" + /// - Single date of other year: "Aug 28, 2023" + /// - Date range of this year: "Mar 23-May 31" + /// - Date range of other year: "Aug 28 - Sep 30, 2023" + /// - Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022" + static String formatDateRange( + DateTime startDate, + DateTime endDate, + Locale? locale, + ) { + final now = DateTime.now(); + final currentYear = now.year; + final localeString = locale?.toString() ?? 'en_US'; + + // Check if it's a single date (same day) + if (startDate.year == endDate.year && + startDate.month == endDate.month && + startDate.day == endDate.day) { + if (startDate.year == currentYear) { + // Single date of this year: "Aug 28" + return DateFormat.MMMd(localeString).format(startDate); + } else { + // Single date of other year: "Aug 28, 2023" + return DateFormat.yMMMd(localeString).format(startDate); + } + } + + // It's a date range + if (startDate.year == endDate.year) { + // Same year + if (startDate.year == currentYear) { + // Date range of this year: "Mar 23-May 31" + final startFormatted = DateFormat.MMMd(localeString).format(startDate); + final endFormatted = DateFormat.MMMd(localeString).format(endDate); + return '$startFormatted - $endFormatted'; + } else { + // Date range of other year: "Aug 28 - Sep 30, 2023" + final startFormatted = DateFormat.MMMd(localeString).format(startDate); + final endFormatted = DateFormat.MMMd(localeString).format(endDate); + return '$startFormatted - $endFormatted, ${startDate.year}'; + } + } else { + // Date range over multiple years: "Apr 17, 2021 - Apr 9, 2022" + final startFormatted = DateFormat.yMMMd(localeString).format(startDate); + final endFormatted = DateFormat.yMMMd(localeString).format(endDate); + return '$startFormatted - $endFormatted'; + } + } +} diff --git a/mobile/lib/infrastructure/repositories/remote_album.repository.dart b/mobile/lib/infrastructure/repositories/remote_album.repository.dart index b77184bce0..c3c4570559 100644 --- a/mobile/lib/infrastructure/repositories/remote_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_album.repository.dart @@ -1,7 +1,13 @@ +import 'dart:async'; + import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; enum SortRemoteAlbumsBy { id, updatedAt } @@ -99,11 +105,169 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository { }); } + Future update(RemoteAlbum album) async { + await _db.remoteAlbumEntity.update().replace( + RemoteAlbumEntityCompanion( + id: Value(album.id), + name: Value(album.name), + ownerId: Value(album.ownerId), + createdAt: Value(album.createdAt), + updatedAt: Value(album.updatedAt), + description: Value(album.description), + thumbnailAssetId: Value(album.thumbnailAssetId), + isActivityEnabled: Value(album.isActivityEnabled), + order: Value(album.order), + ), + ); + } + Future removeAssets(String albumId, List assetIds) { return _db.remoteAlbumAssetEntity.deleteWhere( (tbl) => tbl.albumId.equals(albumId) & tbl.assetId.isIn(assetIds), ); } + + FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { + final query = _db.remoteAlbumAssetEntity.selectOnly() + ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) + ..addColumns([ + _db.remoteAssetEntity.createdAt.min(), + _db.remoteAssetEntity.createdAt.max(), + ]) + ..join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id + .equalsExp(_db.remoteAlbumAssetEntity.assetId), + ), + ]); + + return query.map((row) { + final minDate = row.read(_db.remoteAssetEntity.createdAt.min()); + final maxDate = row.read(_db.remoteAssetEntity.createdAt.max()); + return (minDate ?? DateTime.now(), maxDate ?? DateTime.now()); + }).getSingle(); + } + + Future> getSharedUsers(String albumId) async { + final albumUserRows = await (_db.select(_db.remoteAlbumUserEntity) + ..where((row) => row.albumId.equals(albumId))) + .get(); + + if (albumUserRows.isEmpty) { + return []; + } + + final userIds = albumUserRows.map((row) => row.userId); + + return (_db.select(_db.userEntity)..where((row) => row.id.isIn(userIds))) + .map( + (user) => UserDto( + id: user.id, + email: user.email, + name: user.name, + profileImagePath: user.profileImagePath?.isEmpty == true + ? null + : user.profileImagePath, + isAdmin: user.isAdmin, + updatedAt: user.updatedAt, + quotaSizeInBytes: user.quotaSizeInBytes ?? 0, + quotaUsageInBytes: user.quotaUsageInBytes, + memoryEnabled: true, + inTimeline: false, + isPartnerSharedBy: false, + isPartnerSharedWith: false, + ), + ) + .get(); + } + + Future> getAssets(String albumId) { + final query = _db.remoteAlbumAssetEntity.select().join([ + innerJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId), + ), + ]) + ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)); + + return query + .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) + .get(); + } + + Future addAssets(String albumId, List assetIds) async { + final albumAssets = assetIds.map( + (assetId) => RemoteAlbumAssetEntityCompanion( + albumId: Value(albumId), + assetId: Value(assetId), + ), + ); + + await _db.batch((batch) { + batch.insertAll( + _db.remoteAlbumAssetEntity, + albumAssets, + ); + }); + + return assetIds.length; + } + + Future addUsers(String albumId, List userIds) { + final albumUsers = userIds.map( + (assetId) => RemoteAlbumUserEntityCompanion( + albumId: Value(albumId), + userId: Value(assetId), + role: const Value(AlbumUserRole.editor), + ), + ); + + return _db.batch((batch) { + batch.insertAll( + _db.remoteAlbumUserEntity, + albumUsers, + ); + }); + } + + Future deleteAlbum(String albumId) async { + return _db.transaction(() async { + await _db.remoteAlbumEntity.deleteWhere( + (table) => table.id.equals(albumId), + ); + }); + } + + Stream watchAlbum(String albumId) { + final query = _db.remoteAlbumEntity.select().join([ + leftOuterJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId), + useColumns: false, + ), + leftOuterJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId), + useColumns: false, + ), + ]) + ..where(_db.remoteAlbumEntity.id.equals(albumId)) + ..addColumns([_db.userEntity.name]) + ..groupBy([_db.remoteAlbumEntity.id]); + + return query.map((row) { + final album = row.readTable(_db.remoteAlbumEntity).toDto( + ownerName: row.read(_db.userEntity.name)!, + ); + return album; + }).watchSingleOrNull(); + } } extension on RemoteAlbumEntityData { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index c3c7fc71ab..a125d87d8d 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; @@ -195,41 +196,75 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return _db.remoteAlbumAssetEntity .count(where: (row) => row.albumId.equals(albumId)) .map(_generateBuckets) - .watchSingle(); + .watch() + .map((results) => results.isNotEmpty ? results.first : []) + .handleError((error) { + return []; + }); } - final assetCountExp = _db.remoteAssetEntity.id.count(); - final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + return (_db.remoteAlbumEntity.select() + ..where((row) => row.id.equals(albumId))) + .watch() + .switchMap((albums) { + if (albums.isEmpty) { + return Stream.value([]); + } - final query = _db.remoteAssetEntity.selectOnly() - ..addColumns([assetCountExp, dateExp]) - ..join([ - innerJoin( - _db.remoteAlbumAssetEntity, - _db.remoteAlbumAssetEntity.assetId - .equalsExp(_db.remoteAssetEntity.id), - useColumns: false, - ), - ]) - ..where( - _db.remoteAssetEntity.deletedAt.isNull() & - _db.remoteAlbumAssetEntity.albumId.equals(albumId), - ) - ..groupBy([dateExp]) - ..orderBy([OrderingTerm.desc(dateExp)]); + final album = albums.first; + final isAscending = album.order == AlbumAssetOrder.asc; + final assetCountExp = _db.remoteAssetEntity.id.count(); + final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); - return query.map((row) { - final timeline = row.read(dateExp)!.dateFmt(groupBy); - final assetCount = row.read(assetCountExp)!; - return TimeBucket(date: timeline, assetCount: assetCount); - }).watch(); + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.remoteAlbumAssetEntity, + _db.remoteAlbumAssetEntity.assetId + .equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAlbumAssetEntity.albumId.equals(albumId), + ) + ..groupBy([dateExp]); + + if (isAscending) { + query.orderBy([OrderingTerm.asc(dateExp)]); + } else { + query.orderBy([OrderingTerm.desc(dateExp)]); + } + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + }).handleError((error) { + // If there's an error (e.g., album was deleted), return empty buckets + return []; + }); } Future> _getRemoteAlbumBucketAssets( String albumId, { required int offset, required int count, - }) { + }) async { + final albumData = await (_db.remoteAlbumEntity.select() + ..where((row) => row.id.equals(albumId))) + .getSingleOrNull(); + + // If album doesn't exist (was deleted), return empty list + if (albumData == null) { + return []; + } + + final isAscending = albumData.order == AlbumAssetOrder.asc; + final query = _db.remoteAssetEntity.select().join( [ innerJoin( @@ -239,13 +274,19 @@ class DriftTimelineRepository extends DriftDatabaseRepository { useColumns: false, ), ], - ) - ..where( + )..where( _db.remoteAssetEntity.deletedAt.isNull() & _db.remoteAlbumAssetEntity.albumId.equals(albumId), - ) - ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) - ..limit(count, offset: offset); + ); + + if (isAscending) { + query.orderBy([OrderingTerm.asc(_db.remoteAssetEntity.createdAt)]); + } else { + query.orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]); + } + + query.limit(count, offset: offset); + return query .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) .get(); diff --git a/mobile/lib/pages/album/album_control_button.dart b/mobile/lib/pages/album/album_control_button.dart index b2100946e6..c453ace618 100644 --- a/mobile/lib/pages/album/album_control_button.dart +++ b/mobile/lib/pages/album/album_control_button.dart @@ -1,52 +1,40 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/album/current_album.provider.dart'; -import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; -// ignore: must_be_immutable class AlbumControlButton extends ConsumerWidget { - void Function() onAddPhotosPressed; - void Function() onAddUsersPressed; + final void Function()? onAddPhotosPressed; + final void Function()? onAddUsersPressed; - AlbumControlButton({ + const AlbumControlButton({ super.key, - required this.onAddPhotosPressed, - required this.onAddUsersPressed, + this.onAddPhotosPressed, + this.onAddUsersPressed, }); @override Widget build(BuildContext context, WidgetRef ref) { - final userId = ref.watch(authProvider).userId; - final isOwner = ref.watch( - currentAlbumProvider.select((album) { - return album?.ownerId == userId; - }), - ); - - return Padding( - padding: const EdgeInsets.only(left: 16.0), - child: SizedBox( - height: 36, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ + return SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + if (onAddPhotosPressed != null) AlbumActionFilledButton( key: const ValueKey('add_photos_button'), iconData: Icons.add_photo_alternate_outlined, onPressed: onAddPhotosPressed, labelText: "add_photos".tr(), ), - if (isOwner) - AlbumActionFilledButton( - key: const ValueKey('add_users_button'), - iconData: Icons.person_add_alt_rounded, - onPressed: onAddUsersPressed, - labelText: "album_viewer_page_share_add_users".tr(), - ), - ], - ), + if (onAddUsersPressed != null) + AlbumActionFilledButton( + key: const ValueKey('add_users_button'), + iconData: Icons.person_add_alt_rounded, + onPressed: onAddUsersPressed, + labelText: "album_viewer_page_share_add_users".tr(), + ), + ], ), ); } diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index 86b23fba30..2edf6082ac 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -41,6 +41,11 @@ class AlbumViewer extends HookConsumerWidget { final userId = ref.watch(authProvider).userId; final isMultiselecting = ref.watch(multiselectProvider); final isProcessing = useProcessingOverlay(); + final isOwner = ref.watch( + currentAlbumProvider.select((album) { + return album?.ownerId == userId; + }), + ); Future onRemoveFromAlbumPressed(Iterable assets) async { final bool isSuccess = @@ -138,10 +143,13 @@ class AlbumViewer extends HookConsumerWidget { ), const AlbumSharedUserIcons(), if (album.isRemote) - AlbumControlButton( - key: const ValueKey("albumControlButton"), - onAddPhotosPressed: onAddPhotosPressed, - onAddUsersPressed: onAddUsersPressed, + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: AlbumControlButton( + key: const ValueKey("albumControlButton"), + onAddPhotosPressed: onAddPhotosPressed, + onAddUsersPressed: isOwner ? onAddUsersPressed : null, + ), ), const SizedBox(height: 8), ], diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index e6d3d796a4..d59f734c79 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -13,6 +13,7 @@ import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart'; @@ -478,7 +479,7 @@ class _QuickSortAndViewMode extends StatelessWidget { } } -class _AlbumList extends StatelessWidget { +class _AlbumList extends ConsumerWidget { const _AlbumList({ required this.isLoading, required this.error, @@ -492,7 +493,7 @@ class _AlbumList extends StatelessWidget { final String? userId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (isLoading) { return const SliverToBoxAdapter( child: Center( @@ -567,9 +568,12 @@ class _AlbumList extends StatelessWidget { color: context.colorScheme.onSurfaceSecondary, ), ), - onTap: () => context.router.push( - RemoteAlbumRoute(album: album), - ), + onTap: () { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); + context.router.push( + RemoteAlbumRoute(album: album), + ); + }, leadingPadding: const EdgeInsets.only( right: 16, ), @@ -692,7 +696,7 @@ class _AlbumGrid extends StatelessWidget { } } -class _GridAlbumCard extends StatelessWidget { +class _GridAlbumCard extends ConsumerWidget { const _GridAlbumCard({ required this.album, required this.userId, @@ -702,11 +706,14 @@ class _GridAlbumCard extends StatelessWidget { final String? userId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return GestureDetector( - onTap: () => context.router.push( - RemoteAlbumRoute(album: album), - ), + onTap: () { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); + context.router.push( + RemoteAlbumRoute(album: album), + ); + }, child: Card( elevation: 0, color: context.colorScheme.surfaceBright, diff --git a/mobile/lib/presentation/pages/drift_create_album.page.dart b/mobile/lib/presentation/pages/drift_create_album.page.dart index e06321413e..f6ba98f61c 100644 --- a/mobile/lib/presentation/pages/drift_create_album.page.dart +++ b/mobile/lib/presentation/pages/drift_create_album.page.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/album/album_action_filled_button.dart'; @@ -202,6 +203,7 @@ class _DriftCreateAlbumPageState extends ConsumerState { ); if (album != null) { + ref.read(currentRemoteAlbumProvider.notifier).setAlbum(album); context.replaceRoute( RemoteAlbumRoute(album: album), ); diff --git a/mobile/lib/presentation/pages/drift_remote_album.page.dart b/mobile/lib/presentation/pages/drift_remote_album.page.dart index bbfe6ddc74..6b68bfcd4e 100644 --- a/mobile/lib/presentation/pages/drift_remote_album.page.dart +++ b/mobile/lib/presentation/pages/drift_remote_album.page.dart @@ -1,17 +1,237 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; + import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart'; +import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; -import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/remote_album_sliver_app_bar.dart'; @RoutePage() -class RemoteAlbumPage extends StatelessWidget { +class RemoteAlbumPage extends ConsumerStatefulWidget { final RemoteAlbum album; - const RemoteAlbumPage({super.key, required this.album}); + const RemoteAlbumPage({ + super.key, + required this.album, + }); + + @override + ConsumerState createState() => _RemoteAlbumPageState(); +} + +class _RemoteAlbumPageState extends ConsumerState { + @override + void initState() { + super.initState(); + } + + Future addAssets(BuildContext context) async { + final albumAssets = + await ref.read(remoteAlbumProvider.notifier).getAssets(widget.album.id); + + final newAssets = await context.pushRoute>( + DriftAssetSelectionTimelineRoute( + lockedSelectionAssets: albumAssets.toSet(), + ), + ); + + if (newAssets == null || newAssets.isEmpty) { + return; + } + + final added = await ref.read(remoteAlbumProvider.notifier).addAssets( + widget.album.id, + newAssets.map((asset) { + final remoteAsset = asset as RemoteAsset; + return remoteAsset.id; + }).toList(), + ); + + if (added > 0) { + ImmichToast.show( + context: context, + msg: "assets_added_to_album_count".t( + context: context, + args: { + 'count': added.toString(), + }, + ), + toastType: ToastType.success, + ); + } + } + + Future addUsers(BuildContext context) async { + final newUsers = await context.pushRoute>( + DriftUserSelectionRoute(album: widget.album), + ); + + if (newUsers == null || newUsers.isEmpty) { + return; + } + + try { + await ref + .read(remoteAlbumProvider.notifier) + .addUsers(widget.album.id, newUsers); + + if (newUsers.isNotEmpty) { + ImmichToast.show( + context: context, + msg: "users_added_to_album_count".t( + context: context, + args: { + 'count': newUsers.length, + }, + ), + toastType: ToastType.success, + ); + } + + ref.invalidate(remoteAlbumSharedUsersProvider(widget.album.id)); + } catch (e) { + ImmichToast.show( + context: context, + msg: "Failed to add users to album: ${e.toString()}", + toastType: ToastType.error, + ); + } + } + + Future toggleAlbumOrder() async { + await ref.read(remoteAlbumProvider.notifier).toggleAlbumOrder( + widget.album.id, + ); + + ref.invalidate(timelineServiceProvider); + } + + Future deleteAlbum(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('delete_album'.t(context: context)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'album_delete_confirmation'.t( + context: context, + args: {'album': widget.album.name}, + ), + ), + const SizedBox(height: 8), + Text( + 'album_delete_confirmation_description'.t(context: context), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('cancel'.t(context: context)), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + child: Text('delete_album'.t(context: context)), + ), + ], + ); + }, + ); + + if (confirmed == true) { + try { + await ref + .read(remoteAlbumProvider.notifier) + .deleteAlbum(widget.album.id); + + ImmichToast.show( + context: context, + msg: 'library_deleted' + .t(context: context), // Using existing success message + toastType: ToastType.success, + ); + + context.pushRoute(const DriftAlbumsRoute()); + } catch (e) { + ImmichToast.show( + context: context, + msg: 'album_viewer_appbar_share_err_delete'.t(context: context), + toastType: ToastType.error, + ); + } + } + } + + Future showEditTitleAndDescription(BuildContext context) async { + final result = await showDialog<_EditAlbumData?>( + context: context, + barrierDismissible: true, + builder: (context) => _EditAlbumDialog(album: widget.album), + ); + + if (result != null && context.mounted) { + HapticFeedback.mediumImpact(); + } + } + + void showOptionSheet(BuildContext context) { + final user = ref.watch(currentUserProvider); + final isOwner = user != null ? user.id == widget.album.ownerId : false; + + showModalBottomSheet( + context: context, + backgroundColor: context.colorScheme.surface, + isScrollControlled: false, + builder: (context) { + return DriftRemoteAlbumOption( + onDeleteAlbum: isOwner + ? () async { + await deleteAlbum(context); + if (context.mounted) { + context.pop(); + } + } + : null, + onAddUsers: isOwner + ? () async { + await addUsers(context); + context.pop(); + } + : null, + onAddPhotos: () async { + await addAssets(context); + context.pop(); + }, + onToggleAlbumOrder: () async { + await toggleAlbumOrder(); + context.pop(); + }, + onEditAlbum: () async { + context.pop(); + await showEditTitleAndDescription(context); + }, + ); + }, + ); + } @override Widget build(BuildContext context) { @@ -21,19 +241,207 @@ class RemoteAlbumPage extends StatelessWidget { (ref) { final timelineService = ref .watch(timelineFactoryProvider) - .remoteAlbum(albumId: album.id); + .remoteAlbum(albumId: widget.album.id); ref.onDispose(timelineService.dispose); return timelineService; }, ), ], child: Timeline( - appBar: MesmerizingSliverAppBar( - title: album.name, + appBar: RemoteAlbumSliverAppBar( icon: Icons.photo_album_outlined, + onShowOptions: () => showOptionSheet(context), + onToggleAlbumOrder: () => toggleAlbumOrder(), + onEditTitle: () => showEditTitleAndDescription(context), ), bottomSheet: RemoteAlbumBottomSheet( - album: album, + album: widget.album, + ), + ), + ); + } +} + +class _EditAlbumData { + final String name; + final String? description; + + const _EditAlbumData({ + required this.name, + this.description, + }); +} + +class _EditAlbumDialog extends ConsumerStatefulWidget { + final RemoteAlbum album; + + const _EditAlbumDialog({ + required this.album, + }); + + @override + ConsumerState<_EditAlbumDialog> createState() => _EditAlbumDialogState(); +} + +class _EditAlbumDialogState extends ConsumerState<_EditAlbumDialog> { + late final TextEditingController titleController; + late final TextEditingController descriptionController; + final formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + titleController = TextEditingController(text: widget.album.name); + descriptionController = TextEditingController( + text: widget.album.description.isEmpty ? '' : widget.album.description, + ); + } + + @override + void dispose() { + titleController.dispose(); + descriptionController.dispose(); + super.dispose(); + } + + Future _handleSave() async { + if (formKey.currentState?.validate() != true) return; + + try { + final newTitle = titleController.text.trim(); + final newDescription = descriptionController.text.trim(); + + await ref.read(remoteAlbumProvider.notifier).updateAlbum( + widget.album.id, + name: newTitle, + description: newDescription.isEmpty ? null : newDescription, + ); + + if (mounted) { + Navigator.of(context).pop( + _EditAlbumData( + name: newTitle, + description: newDescription.isEmpty ? null : newDescription, + ), + ); + } + } catch (e) { + if (mounted) { + ImmichToast.show( + context: context, + msg: 'album_update_error'.t(context: context), + toastType: ToastType.error, + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.all(24), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16), + ), + ), + child: SingleChildScrollView( + child: Container( + padding: const EdgeInsets.all(16), + constraints: const BoxConstraints(maxWidth: 550), + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + Icons.edit_outlined, + color: context.colorScheme.primary, + size: 24, + ), + const SizedBox(width: 12), + Text( + 'edit_album'.t(context: context), + style: context.textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 24), + + // Album Name + Text( + 'album_name'.t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + TextFormField( + controller: titleController, + maxLines: 1, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + filled: true, + fillColor: context.colorScheme.surface, + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'album_name_required'.t(context: context); + } + + return null; + }, + ), + const SizedBox(height: 18), + + // Description + Text( + 'description'.t(context: context).toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + TextFormField( + controller: descriptionController, + maxLines: 4, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(12), + ), + ), + filled: true, + fillColor: context.colorScheme.surface, + ), + ), + const SizedBox(height: 24), + + // Action Buttons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: Text('cancel'.t(context: context)), + ), + const SizedBox(width: 12), + FilledButton( + onPressed: _handleSave, + child: Text('save'.t(context: context)), + ), + ], + ), + ], + ), + ), ), ), ); diff --git a/mobile/lib/presentation/pages/drift_user_selection.page.dart b/mobile/lib/presentation/pages/drift_user_selection.page.dart new file mode 100644 index 0000000000..6f5c4c3e2b --- /dev/null +++ b/mobile/lib/presentation/pages/drift_user_selection.page.dart @@ -0,0 +1,215 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/user_metadata.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +// TODO: Refactor this provider when we have user provider/service/repository pattern in place +final driftUsersProvider = + FutureProvider.autoDispose>((ref) async { + final drift = ref.watch(driftProvider); + final currentUser = ref.watch(currentUserProvider); + + final userEntities = await drift.managers.userEntity.get(); + + final users = userEntities + .map( + (entity) => UserDto( + id: entity.id, + name: entity.name, + email: entity.email, + isAdmin: entity.isAdmin, + profileImagePath: entity.profileImagePath, + updatedAt: entity.updatedAt, + quotaSizeInBytes: entity.quotaSizeInBytes ?? 0, + quotaUsageInBytes: entity.quotaUsageInBytes, + isPartnerSharedBy: false, + isPartnerSharedWith: false, + avatarColor: AvatarColor.primary, + memoryEnabled: true, + inTimeline: true, + ), + ) + .toList(); + + users.removeWhere((u) => currentUser?.id == u.id); + + return users; +}); + +@RoutePage() +class DriftUserSelectionPage extends HookConsumerWidget { + final RemoteAlbum album; + + const DriftUserSelectionPage({ + super.key, + required this.album, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue> suggestedShareUsers = + ref.watch(driftUsersProvider); + final sharedUsersList = useState>({}); + + addNewUsersHandler() { + context.maybePop(sharedUsersList.value.map((e) => e.id).toList()); + } + + buildTileIcon(UserDto user) { + if (sharedUsersList.value.contains(user)) { + return CircleAvatar( + backgroundColor: context.primaryColor, + child: const Icon( + Icons.check_rounded, + size: 25, + ), + ); + } else { + return UserCircleAvatar( + user: user, + ); + } + } + + buildUserList(List users) { + List usersChip = []; + + for (var user in sharedUsersList.value) { + usersChip.add( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Chip( + backgroundColor: context.primaryColor.withValues(alpha: 0.15), + label: Text( + user.name, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } + return ListView( + children: [ + Wrap( + children: [...usersChip], + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'suggestions'.tr(), + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + fontWeight: FontWeight.bold, + ), + ), + ), + ListView.builder( + primary: false, + shrinkWrap: true, + itemBuilder: ((context, index) { + return ListTile( + leading: buildTileIcon(users[index]), + dense: true, + title: Text( + users[index].name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + users[index].email, + style: const TextStyle( + fontSize: 12, + ), + ), + onTap: () { + if (sharedUsersList.value.contains(users[index])) { + sharedUsersList.value = sharedUsersList.value + .where( + (selectedUser) => selectedUser.id != users[index].id, + ) + .toSet(); + } else { + sharedUsersList.value = { + ...sharedUsersList.value, + users[index], + }; + } + }, + ); + }), + itemCount: users.length, + ), + ], + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text( + 'invite_to_album', + ).tr(), + elevation: 0, + centerTitle: false, + leading: IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + context.maybePop(null); + }, + ), + actions: [ + TextButton( + onPressed: + sharedUsersList.value.isEmpty ? null : addNewUsersHandler, + child: const Text( + "add", + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + body: suggestedShareUsers.widgetWhen( + onData: (users) { + // Get shared users for this album from the database + final sharedUsers = + ref.watch(remoteAlbumSharedUsersProvider(album.id)); + + return sharedUsers.when( + data: (albumSharedUsers) { + // Filter out users that are already shared with this album and the owner + final filteredUsers = users.where((user) { + return !albumSharedUsers + .any((sharedUser) => sharedUser.id == user.id) && + user.id != album.ownerId; + }).toList(); + + return buildUserList(filteredUsers); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + // If we can't load shared users, just filter out the owner + final filteredUsers = + users.where((user) => user.id != album.ownerId).toList(); + return buildUserList(filteredUsers); + }, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart new file mode 100644 index 0000000000..d32608a8b4 --- /dev/null +++ b/mobile/lib/presentation/widgets/remote_album/drift_album_option.widget.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; + +class DriftRemoteAlbumOption extends ConsumerWidget { + const DriftRemoteAlbumOption({ + super.key, + this.onAddPhotos, + this.onAddUsers, + this.onDeleteAlbum, + this.onLeaveAlbum, + this.onCreateSharedLink, + this.onToggleAlbumOrder, + this.onEditAlbum, + }); + + final VoidCallback? onAddPhotos; + final VoidCallback? onAddUsers; + final VoidCallback? onDeleteAlbum; + final VoidCallback? onLeaveAlbum; + final VoidCallback? onCreateSharedLink; + final VoidCallback? onToggleAlbumOrder; + final VoidCallback? onEditAlbum; + + @override + Widget build(BuildContext context, WidgetRef ref) { + TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.w600, + ); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: ListView( + shrinkWrap: true, + children: [ + if (onEditAlbum != null) + ListTile( + leading: const Icon(Icons.edit), + title: Text( + 'edit_album'.t(context: context), + style: textStyle, + ), + onTap: onEditAlbum, + ), + if (onAddPhotos != null) + ListTile( + leading: const Icon(Icons.add_a_photo), + title: Text( + 'add_photos'.t(context: context), + style: textStyle, + ), + onTap: onAddPhotos, + ), + if (onAddUsers != null) + ListTile( + leading: const Icon(Icons.group_add), + title: Text( + 'album_viewer_page_share_add_users'.t(context: context), + style: textStyle, + ), + onTap: onAddUsers, + ), + if (onLeaveAlbum != null) + ListTile( + leading: const Icon(Icons.person_remove_rounded), + title: Text( + 'leave_album'.t(context: context), + style: textStyle, + ), + onTap: onLeaveAlbum, + ), + if (onToggleAlbumOrder != null) + ListTile( + leading: const Icon(Icons.swap_vert_rounded), + title: Text( + 'change_display_order'.t(context: context), + style: textStyle, + ), + onTap: onToggleAlbumOrder, + ), + if (onCreateSharedLink != null) + ListTile( + leading: const Icon(Icons.link), + title: Text( + 'create_shared_link'.t(context: context), + style: textStyle, + ), + onTap: onCreateSharedLink, + ), + if (onDeleteAlbum != null) ...[ + const Divider( + indent: 16, + endIndent: 16, + ), + ListTile( + leading: Icon( + Icons.delete, + color: + context.isDarkTheme ? Colors.red[400] : Colors.red[800], + ), + title: Text( + 'delete_album'.t(context: context), + style: textStyle.copyWith( + color: + context.isDarkTheme ? Colors.red[400] : Colors.red[800], + ), + ), + onTap: onDeleteAlbum, + ), + ], + ], + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/current_album.provider.dart b/mobile/lib/providers/infrastructure/current_album.provider.dart new file mode 100644 index 0000000000..ece188ee15 --- /dev/null +++ b/mobile/lib/providers/infrastructure/current_album.provider.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; + +final currentRemoteAlbumProvider = + AutoDisposeNotifierProvider( + CurrentAlbumNotifier.new, +); + +class CurrentAlbumNotifier extends AutoDisposeNotifier { + KeepAliveLink? _keepAliveLink; + StreamSubscription? _assetSubscription; + + @override + RemoteAlbum? build() => null; + + void setAlbum(RemoteAlbum album) { + _keepAliveLink?.close(); + _assetSubscription?.cancel(); + state = album; + + _assetSubscription = ref + .watch(remoteAlbumServiceProvider) + .watchAlbum(album.id) + .listen((updatedAlbum) { + if (updatedAlbum != null) { + state = updatedAlbum; + } + }); + _keepAliveLink = ref.keepAlive(); + } + + void dispose() { + _keepAliveLink?.close(); + _assetSubscription?.cancel(); + } +} diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 84db53ab9f..14badd58ed 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -1,5 +1,8 @@ import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart'; @@ -58,7 +61,7 @@ class RemoteAlbumState { } class RemoteAlbumNotifier extends Notifier { - late final RemoteAlbumService _remoteAlbumService; + late RemoteAlbumService _remoteAlbumService; @override RemoteAlbumState build() { @@ -145,4 +148,106 @@ class RemoteAlbumNotifier extends Notifier { rethrow; } } + + Future updateAlbum( + String albumId, { + String? name, + String? description, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + }) async { + state = state.copyWith(isLoading: true, error: null); + + try { + final updatedAlbum = await _remoteAlbumService.updateAlbum( + albumId, + name: name, + description: description, + thumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: order, + ); + + final updatedAlbums = state.albums.map((album) { + return album.id == albumId ? updatedAlbum : album; + }).toList(); + + final updatedFilteredAlbums = state.filteredAlbums.map((album) { + return album.id == albumId ? updatedAlbum : album; + }).toList(); + + state = state.copyWith( + albums: updatedAlbums, + filteredAlbums: updatedFilteredAlbums, + isLoading: false, + ); + + return updatedAlbum; + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + rethrow; + } + } + + Future toggleAlbumOrder(String albumId) async { + final currentAlbum = + state.albums.firstWhere((album) => album.id == albumId); + + final newOrder = currentAlbum.order == AlbumAssetOrder.asc + ? AlbumAssetOrder.desc + : AlbumAssetOrder.asc; + + return updateAlbum(albumId, order: newOrder); + } + + Future deleteAlbum(String albumId) async { + await _remoteAlbumService.deleteAlbum(albumId); + + final updatedAlbums = + state.albums.where((album) => album.id != albumId).toList(); + final updatedFilteredAlbums = + state.filteredAlbums.where((album) => album.id != albumId).toList(); + + state = state.copyWith( + albums: updatedAlbums, + filteredAlbums: updatedFilteredAlbums, + ); + } + + Future> getAssets(String albumId) { + return _remoteAlbumService.getAssets(albumId); + } + + Future addAssets(String albumId, List assetIds) { + return _remoteAlbumService.addAssets( + albumId: albumId, + assetIds: assetIds, + ); + } + + Future addUsers(String albumId, List userIds) { + return _remoteAlbumService.addUsers( + albumId: albumId, + userIds: userIds, + ); + } } + +final remoteAlbumDateRangeProvider = + FutureProvider.family<(DateTime, DateTime), String>( + (ref, albumId) async { + final service = ref.watch(remoteAlbumServiceProvider); + return service.getDateRange(albumId); + }, +); + +final remoteAlbumSharedUsersProvider = + FutureProvider.autoDispose.family, String>( + (ref, albumId) async { + final link = ref.keepAlive(); + ref.onDispose(() => link.close()); + final service = ref.watch(remoteAlbumServiceProvider); + return service.getSharedUsers(albumId); + }, +); diff --git a/mobile/lib/repositories/drift_album_api_repository.dart b/mobile/lib/repositories/drift_album_api_repository.dart index 7ef24f1e7c..26b55fbef6 100644 --- a/mobile/lib/repositories/drift_album_api_repository.dart +++ b/mobile/lib/repositories/drift_album_api_repository.dart @@ -52,6 +52,77 @@ class DriftAlbumApiRepository extends ApiRepository { } return (removed: removed, failed: failed); } + + Future<({List added, List failed})> addAssets( + String albumId, + Iterable assetIds, + ) async { + final response = await checkNull( + _api.addAssetsToAlbum( + albumId, + BulkIdsDto(ids: assetIds.toList()), + ), + ); + final List added = [], failed = []; + for (final dto in response) { + if (dto.success) { + added.add(dto.id); + } else { + failed.add(dto.id); + } + } + + return (added: added, failed: failed); + } + + Future updateAlbum( + String albumId, { + String? name, + String? description, + String? thumbnailAssetId, + bool? isActivityEnabled, + AlbumAssetOrder? order, + }) async { + AssetOrder? apiOrder; + if (order != null) { + apiOrder = + order == AlbumAssetOrder.asc ? AssetOrder.asc : AssetOrder.desc; + } + + final responseDto = await checkNull( + _api.updateAlbumInfo( + albumId, + UpdateAlbumDto( + albumName: name, + description: description, + albumThumbnailAssetId: thumbnailAssetId, + isActivityEnabled: isActivityEnabled, + order: apiOrder, + ), + ), + ); + + return responseDto.toRemoteAlbum(); + } + + Future deleteAlbum(String albumId) { + return _api.deleteAlbum(albumId); + } + + Future addUsers( + String albumId, + Iterable userIds, + ) async { + final albumUsers = + userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList(); + final response = await checkNull( + _api.addUsersToAlbum( + albumId, + AddUsersDto(albumUsers: albumUsers), + ), + ); + return response.toRemoteAlbum(); + } } extension on AlbumResponseDto { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 7cd628606b..ddd630727d 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -75,6 +75,7 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; @@ -463,6 +464,11 @@ class AppRouter extends RootStackRouter { page: DriftPlaceDetailRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftUserSelectionRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index e4719697c2..a8a26836c7 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -964,6 +964,44 @@ class DriftTrashRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftUserSelectionPage] +class DriftUserSelectionRoute + extends PageRouteInfo { + DriftUserSelectionRoute({ + Key? key, + required RemoteAlbum album, + List? children, + }) : super( + DriftUserSelectionRoute.name, + args: DriftUserSelectionRouteArgs(key: key, album: album), + initialChildren: children, + ); + + static const String name = 'DriftUserSelectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftUserSelectionPage(key: args.key, album: args.album); + }, + ); +} + +class DriftUserSelectionRouteArgs { + const DriftUserSelectionRouteArgs({this.key, required this.album}); + + final Key? key; + + final RemoteAlbum album; + + @override + String toString() { + return 'DriftUserSelectionRouteArgs{key: $key, album: $album}'; + } +} + /// generated route for /// [DriftVideoPage] class DriftVideoRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/album/remote_album_shared_user_icons.dart b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart new file mode 100644 index 0000000000..dd1a64abe0 --- /dev/null +++ b/mobile/lib/widgets/album/remote_album_shared_user_icons.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/widgets/common/user_circle_avatar.dart'; + +class RemoteAlbumSharedUserIcons extends ConsumerWidget { + const RemoteAlbumSharedUserIcons({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + if (currentAlbum == null) { + return const SizedBox(); + } + + final sharedUsersAsync = + ref.watch(remoteAlbumSharedUsersProvider(currentAlbum.id)); + + return sharedUsersAsync.maybeWhen( + data: (sharedUsers) { + if (sharedUsers.isEmpty) { + return const SizedBox(); + } + + return SizedBox( + height: 50, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemBuilder: ((context, index) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: UserCircleAvatar( + user: sharedUsers[index], + radius: 18, + size: 36, + hasBorder: true, + ), + ); + }), + itemCount: sharedUsers.length, + ), + ); + }, + orElse: () => const SizedBox(), + ); + } +} diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart new file mode 100644 index 0000000000..2d35a6702b --- /dev/null +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -0,0 +1,687 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/album/remote_album_shared_user_icons.dart'; + +class RemoteAlbumSliverAppBar extends ConsumerStatefulWidget { + const RemoteAlbumSliverAppBar({ + super.key, + this.icon = Icons.camera, + this.onShowOptions, + this.onToggleAlbumOrder, + this.onEditTitle, + }); + + final IconData icon; + final void Function()? onShowOptions; + final void Function()? onToggleAlbumOrder; + final void Function()? onEditTitle; + + @override + ConsumerState createState() => + _MesmerizingSliverAppBarState(); +} + +class _MesmerizingSliverAppBarState + extends ConsumerState { + double _scrollProgress = 0.0; + + double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) { + if (settings?.maxExtent == null || settings?.minExtent == null) { + return 1.0; + } + + final deltaExtent = settings!.maxExtent - settings.minExtent; + if (deltaExtent <= 0.0) { + return 1.0; + } + + return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent) + .clamp(0.0, 1.0); + } + + @override + Widget build(BuildContext context) { + final isMultiSelectEnabled = + ref.watch(multiSelectProvider.select((s) => s.isEnabled)); + + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + if (currentAlbum == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + Color? actionIconColor = Color.lerp( + Colors.white, + context.primaryColor, + _scrollProgress, + ); + + List actionIconShadows = [ + if (_scrollProgress < 0.95) + Shadow( + offset: const Offset(0, 2), + blurRadius: 5, + color: Colors.black.withValues(alpha: 0.5), + ) + else + const Shadow( + offset: Offset(0, 2), + blurRadius: 0, + color: Colors.transparent, + ), + ]; + + return isMultiSelectEnabled + ? SliverToBoxAdapter( + child: switch (_scrollProgress) { + < 0.8 => const SizedBox(height: 120), + _ => const SizedBox(height: 452), + }, + ) + : SliverAppBar( + expandedHeight: 400.0, + floating: false, + pinned: true, + snap: false, + elevation: 0, + leading: IconButton( + icon: Icon( + Platform.isIOS + ? Icons.arrow_back_ios_new_rounded + : Icons.arrow_back, + color: actionIconColor, + shadows: actionIconShadows, + ), + onPressed: () { + ref.read(remoteAlbumProvider.notifier).refresh(); + context.pop(); + }, + ), + actions: [ + if (widget.onToggleAlbumOrder != null) + IconButton( + icon: Icon( + Icons.swap_vert_rounded, + color: actionIconColor, + shadows: actionIconShadows, + ), + onPressed: widget.onToggleAlbumOrder, + ), + if (widget.onShowOptions != null) + IconButton( + icon: Icon( + Icons.more_vert, + color: actionIconColor, + shadows: actionIconShadows, + ), + onPressed: widget.onShowOptions, + ), + ], + flexibleSpace: Builder( + builder: (context) { + final settings = context.dependOnInheritedWidgetOfExactType< + FlexibleSpaceBarSettings>(); + final scrollProgress = _calculateScrollProgress(settings); + + // Update scroll progress for the leading button + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && _scrollProgress != scrollProgress) { + setState(() { + _scrollProgress = scrollProgress; + }); + } + }); + + return FlexibleSpaceBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: scrollProgress > 0.95 + ? Text( + currentAlbum.name, + style: TextStyle( + color: context.primaryColor, + fontWeight: FontWeight.w600, + fontSize: 18, + ), + ) + : null, + ), + background: _ExpandedBackground( + scrollProgress: scrollProgress, + icon: widget.icon, + onEditTitle: widget.onEditTitle, + ), + ); + }, + ), + ); + } +} + +class _ExpandedBackground extends ConsumerStatefulWidget { + final double scrollProgress; + final IconData icon; + final void Function()? onEditTitle; + + const _ExpandedBackground({ + required this.scrollProgress, + required this.icon, + this.onEditTitle, + }); + + @override + ConsumerState<_ExpandedBackground> createState() => + _ExpandedBackgroundState(); +} + +class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> + with SingleTickerProviderStateMixin { + late AnimationController _slideController; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + + _slideController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _slideAnimation = Tween( + begin: const Offset(0, 1.5), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _slideController, + curve: Curves.easeOutCubic, + ), + ); + + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) { + _slideController.forward(); + } + }); + } + + @override + void dispose() { + _slideController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final timelineService = ref.watch(timelineServiceProvider); + final currentAlbum = ref.watch(currentRemoteAlbumProvider); + + if (currentAlbum == null) { + return const SizedBox.shrink(); + } + + final dateRange = ref.watch( + remoteAlbumDateRangeProvider(currentAlbum.id), + ); + return Stack( + fit: StackFit.expand, + children: [ + Transform.translate( + offset: Offset(0, widget.scrollProgress * 50), + child: Transform.scale( + scale: 1.4 - (widget.scrollProgress * 0.2), + child: _RandomAssetBackground( + timelineService: timelineService, + icon: widget.icon, + ), + ), + ), + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: widget.scrollProgress * 2.0, + sigmaY: widget.scrollProgress * 2.0, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.05), + Colors.transparent, + Colors.black.withValues(alpha: 0.3), + Colors.black.withValues( + alpha: 0.6 + (widget.scrollProgress * 0.25), + ), + ], + stops: const [0.0, 0.15, 0.55, 1.0], + ), + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + right: 16, + child: SlideTransition( + position: _slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (dateRange.hasValue) + Text( + DateRangeFormatting.formatDateRange( + dateRange.value!.$1.toLocal(), + dateRange.value!.$2.toLocal(), + context.locale, + ), + style: const TextStyle( + color: Colors.white, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black87, + ), + ], + ), + ), + const Text( + " • ", + style: TextStyle( + color: Colors.white, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black87, + ), + ], + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 300), + child: const _ItemCountText(), + ), + ], + ), + GestureDetector( + onTap: widget.onEditTitle, + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + currentAlbum.name, + maxLines: 1, + style: const TextStyle( + color: Colors.white, + fontSize: 36, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black54, + ), + ], + ), + ), + ), + ), + ), + if (currentAlbum.description.isNotEmpty) + GestureDetector( + onTap: widget.onEditTitle, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 80, + ), + child: SingleChildScrollView( + child: Text( + currentAlbum.description, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + shadows: [ + Shadow( + offset: Offset(0, 2), + blurRadius: 8, + color: Colors.black54, + ), + ], + ), + ), + ), + ), + ), + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: RemoteAlbumSharedUserIcons(), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _ItemCountText extends ConsumerStatefulWidget { + const _ItemCountText(); + + @override + ConsumerState<_ItemCountText> createState() => _ItemCountTextState(); +} + +class _ItemCountTextState extends ConsumerState<_ItemCountText> { + StreamSubscription? _reloadSubscription; + + @override + void initState() { + super.initState(); + _reloadSubscription = + EventStream.shared.listen((_) => setState(() {})); + } + + @override + void dispose() { + _reloadSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final assetCount = ref.watch( + timelineServiceProvider.select((s) => s.totalAssets), + ); + + return Text( + 'items_count'.t( + context: context, + args: {"count": assetCount}, + ), + style: context.textTheme.labelLarge?.copyWith( + color: Colors.white, + shadows: [ + const Shadow( + offset: Offset(0, 2), + blurRadius: 12, + color: Colors.black87, + ), + ], + ), + ); + } +} + +class _RandomAssetBackground extends StatefulWidget { + final TimelineService timelineService; + final IconData icon; + + const _RandomAssetBackground({ + required this.timelineService, + required this.icon, + }); + + @override + State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState(); +} + +class _RandomAssetBackgroundState extends State<_RandomAssetBackground> + with TickerProviderStateMixin { + late AnimationController _zoomController; + late AnimationController _crossFadeController; + late Animation _zoomAnimation; + late Animation _panAnimation; + late Animation _crossFadeAnimation; + BaseAsset? _currentAsset; + BaseAsset? _nextAsset; + bool _isZoomingIn = true; + + @override + void initState() { + super.initState(); + + _zoomController = AnimationController( + duration: const Duration(seconds: 12), + vsync: this, + ); + + _crossFadeController = AnimationController( + duration: const Duration(milliseconds: 1200), + vsync: this, + ); + + _zoomAnimation = Tween( + begin: 1.0, + end: 1.2, + ).animate( + CurvedAnimation( + parent: _zoomController, + curve: Curves.easeInOut, + ), + ); + + _panAnimation = Tween( + begin: Offset.zero, + end: const Offset(0.5, -0.5), + ).animate( + CurvedAnimation( + parent: _zoomController, + curve: Curves.easeInOut, + ), + ); + + _crossFadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate( + CurvedAnimation( + parent: _crossFadeController, + curve: Curves.easeInOutCubic, + ), + ); + + Future.delayed( + Durations.medium1, + () => _loadFirstAsset(), + ); + } + + @override + void dispose() { + _zoomController.dispose(); + _crossFadeController.dispose(); + super.dispose(); + } + + void _startAnimationCycle() { + if (_isZoomingIn) { + _zoomController.forward().then((_) { + _loadNextAsset(); + }); + } else { + _zoomController.reverse().then((_) { + _loadNextAsset(); + }); + } + } + + Future _loadFirstAsset() async { + if (!mounted) { + return; + } + + if (widget.timelineService.totalAssets == 0) { + setState(() { + _currentAsset = null; + }); + + return; + } + + setState(() { + _currentAsset = widget.timelineService.getRandomAsset(); + }); + + await _crossFadeController.forward(); + + if (_zoomController.status == AnimationStatus.dismissed) { + if (_isZoomingIn) { + _zoomController.reset(); + } else { + _zoomController.value = 1.0; + } + _startAnimationCycle(); + } + } + + Future _loadNextAsset() async { + if (!mounted) { + return; + } + + try { + if (widget.timelineService.totalAssets > 1) { + // Load next asset while keeping current one visible + final nextAsset = widget.timelineService.getRandomAsset(); + + setState(() { + _nextAsset = nextAsset; + }); + + await _crossFadeController.reverse(); + setState(() { + _currentAsset = _nextAsset; + _nextAsset = null; + }); + + _crossFadeController.value = 1.0; + + _isZoomingIn = !_isZoomingIn; + + _startAnimationCycle(); + } + } catch (e) { + _zoomController.reset(); + _startAnimationCycle(); + } + } + + @override + Widget build(BuildContext context) { + if (widget.timelineService.totalAssets == 0) { + return const SizedBox.shrink(); + } + + return AnimatedBuilder( + animation: Listenable.merge( + [_zoomAnimation, _panAnimation, _crossFadeAnimation], + ), + builder: (context, child) { + return Transform.scale( + scale: _zoomAnimation.value, + filterQuality: Platform.isAndroid ? FilterQuality.low : null, + child: Transform.translate( + offset: _panAnimation.value, + filterQuality: Platform.isAndroid ? FilterQuality.low : null, + child: Stack( + fit: StackFit.expand, + children: [ + // Current image + if (_currentAsset != null) + Opacity( + opacity: _crossFadeAnimation.value, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_currentAsset!), + fit: BoxFit.cover, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return Container(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon( + Icons.error_outline_rounded, + size: 24, + color: Colors.red[300], + ), + ); + }, + ), + ), + ), + + if (_nextAsset != null) + Opacity( + opacity: 1.0 - _crossFadeAnimation.value, + child: SizedBox( + width: double.infinity, + height: double.infinity, + child: Image( + alignment: Alignment.topRight, + image: getFullImageProvider(_nextAsset!), + fit: BoxFit.cover, + frameBuilder: + (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const SizedBox.shrink(); + }, + errorBuilder: (context, error, stackTrace) { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Icon( + Icons.error_outline_rounded, + size: 24, + color: Colors.red[300], + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index e8501f1184..479c30d6da 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -14,11 +14,13 @@ class UserCircleAvatar extends ConsumerWidget { final UserDto user; double radius; double size; + bool hasBorder; UserCircleAvatar({ super.key, this.radius = 22, this.size = 44, + this.hasBorder = false, required this.user, }); @@ -38,25 +40,39 @@ class UserCircleAvatar extends ConsumerWidget { ), child: Text(user.name[0].toUpperCase()), ); - return CircleAvatar( - backgroundColor: userAvatarColor, - radius: radius, - child: user.profileImagePath == null - ? textIcon - : ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(50)), - child: CachedNetworkImage( - fit: BoxFit.cover, - cacheKey: user.profileImagePath, - width: size, - height: size, - placeholder: (_, __) => Image.memory(kTransparentImage), - imageUrl: profileImageUrl, - httpHeaders: ApiService.getRequestHeaders(), - fadeInDuration: const Duration(milliseconds: 300), - errorWidget: (context, error, stackTrace) => textIcon, - ), - ), + return Tooltip( + message: user.name, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: hasBorder + ? Border.all( + color: Colors.grey[500]!, + width: 1, + ) + : null, + ), + child: CircleAvatar( + backgroundColor: userAvatarColor, + radius: radius, + child: user.profileImagePath == null + ? textIcon + : ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(50)), + child: CachedNetworkImage( + fit: BoxFit.cover, + cacheKey: user.profileImagePath, + width: size, + height: size, + placeholder: (_, __) => Image.memory(kTransparentImage), + imageUrl: profileImageUrl, + httpHeaders: ApiService.getRequestHeaders(), + fadeInDuration: const Duration(milliseconds: 300), + errorWidget: (context, error, stackTrace) => textIcon, + ), + ), + ), + ), ); } } diff --git a/mobile/test/modules/extensions/datetime_extensions_test.dart b/mobile/test/modules/extensions/datetime_extensions_test.dart new file mode 100644 index 0000000000..412d946b1f --- /dev/null +++ b/mobile/test/modules/extensions/datetime_extensions_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/extensions/datetime_extensions.dart'; +import 'package:intl/date_symbol_data_local.dart'; + +void main() { + setUpAll(() async { + await initializeDateFormatting(); + }); + + group('DateRangeFormatting.formatDateRange', () { + final currentYear = DateTime.now().year; + + test('returns single date format for this year', () { + final date = DateTime(currentYear, 8, 28); // Aug 28 this year + final result = DateRangeFormatting.formatDateRange(date, date, null); + expect(result, 'Aug 28'); + }); + + test('returns single date format for other year', () { + final date = DateTime(2023, 8, 28); // Aug 28, 2023 + final result = DateRangeFormatting.formatDateRange(date, date, null); + expect(result, 'Aug 28, 2023'); + }); + + test('returns date range format for this year', () { + final startDate = DateTime(currentYear, 3, 23); // Mar 23 + final endDate = DateTime(currentYear, 5, 31); // May 31 + final result = + DateRangeFormatting.formatDateRange(startDate, endDate, null); + expect(result, 'Mar 23 - May 31'); + }); + + test('returns date range format for other year (same year)', () { + final startDate = DateTime(2023, 8, 28); // Aug 28 + final endDate = DateTime(2023, 9, 30); // Sep 30 + final result = + DateRangeFormatting.formatDateRange(startDate, endDate, null); + expect(result, 'Aug 28 - Sep 30, 2023'); + }); + + test('returns date range format over multiple years', () { + final startDate = DateTime(2021, 4, 17); // Apr 17, 2021 + final endDate = DateTime(2022, 4, 9); // Apr 9, 2022 + final result = + DateRangeFormatting.formatDateRange(startDate, endDate, null); + expect(result, 'Apr 17, 2021 - Apr 9, 2022'); + }); + }); +} From 743b6644e96366a4e32e4c0182bf6f478bf3a32c Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Tue, 15 Jul 2025 21:17:24 -0500 Subject: [PATCH 04/45] feat(widgets): iOS widget improvements (#19893) * improvements to error handling, ability to select "Favorites" as a virtual album, fix widgets not showing image when tinting homescreen * dont include isFavorite all the time * remove check for if the album exists this will never run because we default to Album.NONE and its impossible to distinguish between no album selected and album DNE (we dont know what the store ID is, only what iOS gives) --- mobile/ios/WidgetExtension/ImageEntry.swift | 39 +++------ .../ios/WidgetExtension/ImageWidgetView.swift | 15 ++++ mobile/ios/WidgetExtension/ImmichAPI.swift | 61 ++++++++++---- .../widgets/MemoryWidget.swift | 51 +++++------ .../widgets/RandomWidget.swift | 84 ++++++++----------- 5 files changed, 130 insertions(+), 120 deletions(-) diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index 5239702d4b..ee371703a8 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -14,19 +14,6 @@ struct ImageEntry: TimelineEntry { var deepLink: URL? = nil } - // Resizes the stored image to a maximum width of 450 pixels - mutating func resize() { - if image == nil || image!.size.height < 450 || image!.size.width < 450 { - return - } - - image = image?.resized(toWidth: 450) - - if image == nil { - metadata.error = .unableToResize - } - } - static func build( api: ImmichAPI, asset: Asset, @@ -83,7 +70,10 @@ struct ImageEntry: TimelineEntry { guard let imageData = try? Data(contentsOf: imageURL), let metadataJSON = try? Data(contentsOf: metadataURL), - let decodedMetadata = try? JSONDecoder().decode(Metadata.self, from: metadataJSON) + let decodedMetadata = try? JSONDecoder().decode( + Metadata.self, + from: metadataJSON + ) else { return nil } @@ -98,7 +88,7 @@ struct ImageEntry: TimelineEntry { return nil } - static func handleCacheFallback( + static func handleError( for key: String, error: WidgetError = .fetchFailed ) -> Timeline { @@ -108,35 +98,32 @@ struct ImageEntry: TimelineEntry { metadata: EntryMetadata(error: error) ) - // skip cache if album not found or no login - // we want to show these errors to the user since without intervention, + // use cache if generic failed error + // we want to show the other errors to the user since without intervention, // it will never succeed - if error != .noLogin && error != .albumNotFound { - if let cachedEntry = ImageEntry.loadCached(for: key) { - timelineEntry = cachedEntry - } + if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key) + { + timelineEntry = cachedEntry } return Timeline(entries: [timelineEntry], policy: .atEnd) } + } func generateRandomEntries( api: ImmichAPI, now: Date, count: Int, - albumId: String? = nil, + filter: SearchFilter = Album.NONE.filter, subtitle: String? = nil ) async throws -> [ImageEntry] { var entries: [ImageEntry] = [] - let albumIds = albumId != nil ? [albumId!] : [] - let randomAssets = try await api.fetchSearchResults( - with: SearchFilters(size: count, albumIds: albumIds) - ) + let randomAssets = try await api.fetchSearchResults(with: filter) await withTaskGroup(of: ImageEntry?.self) { group in for (dateOffset, asset) in randomAssets.enumerated() { diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index 24072b0607..8e810b051e 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -1,6 +1,18 @@ import SwiftUI import WidgetKit +extension Image { + @ViewBuilder + func tintedWidgetImageModifier() -> some View { + if #available(iOS 18.0, *) { + self + .widgetAccentedRenderingMode(.accentedDesaturated) + } else { + self + } + } +} + struct ImmichWidgetView: View { var entry: ImageEntry @@ -8,6 +20,7 @@ struct ImmichWidgetView: View { if entry.image == nil { VStack { Image("LaunchImage") + .tintedWidgetImageModifier() Text(entry.metadata.error?.errorDescription ?? "") .minimumScaleFactor(0.25) .multilineTextAlignment(.center) @@ -19,7 +32,9 @@ struct ImmichWidgetView: View { Color.clear.overlay( Image(uiImage: entry.image!) .resizable() + .tintedWidgetImageModifier() .scaledToFill() + ) VStack { Spacer() diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index 7cefd9d5ee..36758b824c 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -2,14 +2,20 @@ import Foundation import SwiftUI import WidgetKit +let IMMICH_SHARE_GROUP = "group.app.immich.share" + enum WidgetError: Error, Codable { case noLogin case fetchFailed - case unknown case albumNotFound + case noAssetsAvailable +} + +enum FetchError: Error { case unableToResize case invalidImage case invalidURL + case fetchFailed } extension WidgetError: LocalizedError { @@ -24,14 +30,8 @@ extension WidgetError: LocalizedError { case .albumNotFound: return "Album not found" - case .invalidURL: - return "An invalid URL was used" - - case .invalidImage: - return "An invalid image was received" - - default: - return "An unknown error occured" + case .noAssetsAvailable: + return "No assets available" } } } @@ -52,10 +52,11 @@ struct Asset: Codable { } } -struct SearchFilters: Codable { - var type: AssetType = .image - let size: Int +struct SearchFilter: Codable { + var type = AssetType.image + var size = 1 var albumIds: [String] = [] + var isFavorite: Bool? = nil } struct MemoryResult: Codable { @@ -70,12 +71,35 @@ struct MemoryResult: Codable { let data: MemoryData } -struct Album: Codable { +struct Album: Codable, Equatable { let id: String let albumName: String -} -let IMMICH_SHARE_GROUP = "group.app.immich.share" + static let NONE = Album(id: "NONE", albumName: "None") + static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites") + + var filter: SearchFilter { + switch self { + case Album.NONE: + return SearchFilter() + case Album.FAVORITES: + return SearchFilter(isFavorite: true) + + // regular album + default: + return SearchFilter(albumIds: [id]) + } + } + + var isVirtual: Bool { + switch self { + case Album.NONE, Album.FAVORITES: + return true + default: + return false + } + } +} // MARK: API @@ -132,7 +156,8 @@ class ImmichAPI { return components?.url } - func fetchSearchResults(with filters: SearchFilters) async throws + func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter) + async throws -> [Asset] { // get URL @@ -178,7 +203,7 @@ class ImmichAPI { return try JSONDecoder().decode([MemoryResult].self, from: data) } - func fetchImage(asset: Asset) async throws(WidgetError) -> UIImage { + func fetchImage(asset: Asset) async throws(FetchError) -> UIImage { let thumbnailParams = [URLQueryItem(name: "size", value: "preview")] let assetEndpoint = "/assets/" + asset.id + "/thumbnail" @@ -199,7 +224,7 @@ class ImmichAPI { let decodeOptions: [NSString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: 400, + kCGImageSourceThumbnailMaxPixelSize: 512, kCGImageSourceCreateThumbnailWithTransform: true, ] diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index da7b887c37..d0a3e8c29d 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -23,26 +23,27 @@ struct ImmichMemoryProvider: TimelineProvider { Task { guard let api = try? await ImmichAPI() else { - completion(ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!) + completion( + ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! + ) return } guard let memories = try? await api.fetchMemory(for: Date.now) else { - completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) + completion(ImageEntry.handleError(for: cacheKey).entries.first!) return } for memory in memories { if let asset = memory.assets.first(where: { $0.type == .image }), - var entry = try? await ImageEntry.build( + let entry = try? await ImageEntry.build( api: api, asset: asset, dateOffset: 0, subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) ) { - entry.resize() completion(entry) return } @@ -50,20 +51,17 @@ struct ImmichMemoryProvider: TimelineProvider { // fallback to random image guard - let randomImage = try? await api.fetchSearchResults( - with: SearchFilters(size: 1) - ).first, - var imageEntry = try? await ImageEntry.build( + let randomImage = try? await api.fetchSearchResults().first, + let imageEntry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) + completion(ImageEntry.handleError(for: cacheKey).entries.first!) return } - imageEntry.resize() completion(imageEntry) } } @@ -80,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider { guard let api = try? await ImmichAPI() else { completion( - ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) + ImageEntry.handleError(for: cacheKey, error: .noLogin) ) return } @@ -119,25 +117,28 @@ struct ImmichMemoryProvider: TimelineProvider { // If we didnt add any memory images (some failure occured or no images in memory), // default to 12 hours of random photos if entries.count == 0 { - entries.append( - contentsOf: (try? await generateRandomEntries( + // this must be a do/catch since we need to + // distinguish between a network fail and an empty search + do { + let search = try await generateRandomEntries( api: api, now: now, count: 12 - )) ?? [] - ) - } + ) - // If we fail to fetch images, we still want to add an entry - // with a nil image and an error - if entries.count == 0 { - completion(ImageEntry.handleCacheFallback(for: cacheKey)) - return - } + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + completion( + ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + ) + return + } - // Resize all images to something that can be stored by iOS - for i in entries.indices { - entries[i].resize() + entries.append(contentsOf: search) + } catch { + completion(ImageEntry.handleError(for: cacheKey)) + return + } } // cache the last image diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 8f9143cedd..37f3c5e596 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -8,20 +8,21 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable { struct AlbumQuery: EntityQuery { func entities(for identifiers: [Album.ID]) async throws -> [Album] { - // use cached albums to search - var albums = (try? await AlbumCache.shared.getAlbums()) ?? [] - albums.insert(NO_ALBUM, at: 0) - - return albums.filter { + return await suggestedEntities().filter { identifiers.contains($0.id) } } - func suggestedEntities() async throws -> [Album] { - var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? [] - albums.insert(NO_ALBUM, at: 0) + func suggestedEntities() async -> [Album] { + let albums = (try? await AlbumCache.shared.getAlbums()) ?? [] - return albums + let options = + [ + NONE, + FAVORITES, + ] + albums + + return options } } @@ -35,8 +36,6 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable { } } -let NO_ALBUM = Album(id: "NONE", albumName: "None") - struct RandomConfigurationAppIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource { "Select Album" } static var description: IntentDescription { @@ -64,26 +63,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { -> ImageEntry { let cacheKey = "random_none_\(context.family.rawValue)" - + guard let api = try? await ImmichAPI() else { - return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first! + return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries + .first! } guard let randomImage = try? await api.fetchSearchResults( - with: SearchFilters(size: 1) + with: Album.NONE.filter ).first, - var entry = try? await ImageEntry.build( + let entry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - return ImageEntry.handleCacheFallback(for: cacheKey).entries.first! + return ImageEntry.handleError(for: cacheKey).entries.first! } - entry.resize() - return entry } @@ -97,52 +95,36 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { let now = Date() // nil if album is NONE or nil - let albumId = - configuration.album?.id != "NONE" ? configuration.album?.id : nil - let albumName: String? = - albumId != nil ? configuration.album?.albumName : nil + let album = configuration.album ?? Album.NONE + let albumName = album.isVirtual ? nil : album.albumName - let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)" + let cacheKey = "random_\(album.id)_\(context.family.rawValue)" // If we don't have a server config, return an entry with an error guard let api = try? await ImmichAPI() else { - return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) - } - - if albumId != nil { - // make sure the album exists on server, otherwise show error - guard let albums = try? await api.fetchAlbums() else { - return ImageEntry.handleCacheFallback(for: cacheKey) - } - - if !albums.contains(where: { $0.id == albumId }) { - return ImageEntry.handleCacheFallback( - for: cacheKey, - error: .albumNotFound - ) - } + return ImageEntry.handleError(for: cacheKey, error: .noLogin) } // build entries - entries.append( - contentsOf: (try? await generateRandomEntries( + // this must be a do/catch since we need to + // distinguish between a network fail and an empty search + do { + let search = try await generateRandomEntries( api: api, now: now, count: 12, - albumId: albumId, + filter: album.filter, subtitle: configuration.showAlbumName ? albumName : nil - )) - ?? [] - ) + ) - // Load or save a cached asset for when network conditions are bad - if entries.count == 0 { - return ImageEntry.handleCacheFallback(for: cacheKey) - } + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + } - // Resize all images to something that can be stored by iOS - for i in entries.indices { - entries[i].resize() + entries.append(contentsOf: search) + } catch { + return ImageEntry.handleError(for: cacheKey) } // cache the last image From 32f23b8d383fa6214f83c29150ca4d5cb97af068 Mon Sep 17 00:00:00 2001 From: evan314159 <110177090+evan314159@users.noreply.github.com> Date: Wed, 16 Jul 2025 22:34:45 +0800 Subject: [PATCH 05/45] fix(web): prevent flashing white background in dark mode on page load/reload (#19934) * prevent flashing white background in dark mode on page load/reload, tested with Safari and Chrome on macOS * integrate into existing FOUC-prevention --------- Co-authored-by: Evan --- web/src/app.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/src/app.html b/web/src/app.html index 776764850f..ec8c4393ff 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -21,6 +21,11 @@ html { height: 100%; width: 100%; + background-color: rgb(255, 255, 255); + } + + html.dark { + background-color: rgb(10, 10, 10); } body, @@ -29,6 +34,10 @@ padding: 0; } + body { + transition: background-color 0.15s ease; + } + @keyframes delayedVisibility { to { visibility: visible; From d9891f759eb8331256698fce22ed73c8f0598371 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Wed, 16 Jul 2025 16:35:36 +0200 Subject: [PATCH 06/45] chore: use upstream setup-dcm action (#19963) --- .github/workflows/static_analysis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 0344189043..4cb788f5fa 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -61,8 +61,7 @@ jobs: run: dart pub get - name: Install DCM - # TODO: Move to upstream after https://github.com/CQLabs/setup-dcm/pull/235 merges - uses: bo0tzz/setup-dcm@b4952ab813659c03513b57bd78bfe3f634171f8a + uses: CQLabs/setup-dcm@8697ae0790c0852e964a6ef1d768d62a6675481a # v2.0.1 with: github-token: ${{ secrets.GITHUB_TOKEN }} version: auto From bfceed15da6e00d505722a0391a09101688c2d9a Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:06:17 +0530 Subject: [PATCH 07/45] feat: add license page to app bar dialog (#19971) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- i18n/en.json | 1 + mobile/lib/main.dart | 12 ++++++ mobile/lib/utils/licenses.dart | 42 +++++++++++++++++++ .../common/app_bar_dialog/app_bar_dialog.dart | 24 +++++++++++ 4 files changed, 79 insertions(+) create mode 100644 mobile/lib/utils/licenses.dart diff --git a/i18n/en.json b/i18n/en.json index d2acfcc58f..6b9a0bffe6 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1146,6 +1146,7 @@ "library_page_sort_created": "Created date", "library_page_sort_last_modified": "Last modified", "library_page_sort_title": "Album title", + "licenses": "Licenses", "light": "Light", "like_deleted": "Like deleted", "link_motion_video": "Link motion video", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index bf79c28361..f67767f767 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -31,6 +31,7 @@ import 'package:immich_mobile/utils/bootstrap.dart'; import 'package:immich_mobile/utils/cache/widgets_binding.dart'; import 'package:immich_mobile/utils/download.dart'; import 'package:immich_mobile/utils/http_ssl_options.dart'; +import 'package:immich_mobile/utils/licenses.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:logging/logging.dart'; @@ -98,6 +99,17 @@ Future initApp() async { ); await FileDownloader().trackTasks(); + + LicenseRegistry.addLicense( + () async* { + for (final license in nonPubLicenses.entries) { + yield LicenseEntryWithLineBreaks( + [license.key], + license.value, + ); + } + }, + ); } class ImmichApp extends ConsumerStatefulWidget { diff --git a/mobile/lib/utils/licenses.dart b/mobile/lib/utils/licenses.dart new file mode 100644 index 0000000000..5ebc2c7b1a --- /dev/null +++ b/mobile/lib/utils/licenses.dart @@ -0,0 +1,42 @@ +const nonPubLicenses = { + 'aves': ''' +BSD 3-Clause License + +Copyright (c) 2020, Thibault Deckers +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +''', + 'photo_view': ''' +Copyright 2024 Renan C. Araújo + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +''', +}; diff --git a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart index 9d8be7c6ce..388f202b6d 100644 --- a/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart @@ -17,6 +17,8 @@ import 'package:immich_mobile/utils/bytes_units.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart'; import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_server_info.dart'; import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; +import 'package:immich_mobile/widgets/common/immich_logo.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; class ImmichAppBarDialog extends HookConsumerWidget { @@ -255,6 +257,28 @@ class ImmichAppBarDialog extends HookConsumerWidget { style: context.textTheme.bodySmall, ).tr(), ), + const SizedBox( + width: 20, + child: Text( + "•", + textAlign: TextAlign.center, + ), + ), + InkWell( + onTap: () async { + context.pop(); + final packageInfo = await PackageInfo.fromPlatform(); + showLicensePage( + context: context, + applicationIcon: const Padding( + padding: EdgeInsetsGeometry.symmetric(vertical: 10), + child: ImmichLogo(size: 40), + ), + applicationVersion: packageInfo.version, + ); + }, + child: Text("licenses", style: context.textTheme.bodySmall).tr(), + ), ], ), ); From eae2471ab516a1b4aa21dd2283b50b089261589a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Jul 2025 16:22:33 -0500 Subject: [PATCH 08/45] chore(deps): update base-image to v202507162011 (major) (#19983) chore(deps): update base-image to v202507162011 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 1f5438d070..0b3b864c95 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202507091427@sha256:733e510024e03bc2450608a1f3622f45bf287b70d89a06e60acbccea4bf4fd8c AS dev +FROM ghcr.io/immich-app/base-server-dev:202507162011@sha256:85d4230c2208646bd6c528db41b2213d780b11b7a311397ca6a2aaba7cf697c8 AS dev WORKDIR /usr/src/app COPY server/package.json server/package-lock.json ./ @@ -98,7 +98,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:202507091427@sha256:ad10451acd8eda05a006a2586b6b0425cdaaca97c413fb4c1a10896712528bdc +FROM ghcr.io/immich-app/base-server-prod:202507162011@sha256:636f3ddb6106628ef851d51c23f3fa2c6e4829390cc315b27b38c288c82b23a7 WORKDIR /usr/src/app ENV NODE_ENV=production \ From 649221176c201b14e596323205f16b9dbcfdddf2 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 17 Jul 2025 00:25:41 +0300 Subject: [PATCH 09/45] refactor(mobile): delete local button in new timeline (#19961) * delete local action button * include source * move prompt * batch --- i18n/en.json | 1 + .../repositories/local_asset.repository.dart | 13 +++++++ .../delete_local_action_button.widget.dart | 35 ++++++++++++++++- .../asset_viewer/bottom_sheet.widget.dart | 2 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../local_album_bottom_sheet.widget.dart | 3 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 38 +++++++++++++++---- mobile/lib/services/action.service.dart | 13 +++++++ 11 files changed, 99 insertions(+), 14 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6b9a0bffe6..175399c7ab 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -749,6 +749,7 @@ "delete_key": "Delete key", "delete_library": "Delete Library", "delete_link": "Delete link", + "delete_local_action_prompt": "{count} deleted locally", "delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", "delete_local_dialog_ok_force": "Delete Anyway", "delete_others": "Delete others", diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index cb6871cd22..31a11f7047 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; @@ -43,4 +44,16 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } }); } + + Future delete(List ids) { + if (ids.isEmpty) { + return Future.value(); + } + + return _db.batch((batch) { + for (final slice in ids.slices(32000)) { + batch.deleteWhere(_db.localAssetEntity, (e) => e.id.isIn(slice)); + } + }); + } } diff --git a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart index dfc84b4190..90534ca68c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/delete_local_action_button.widget.dart @@ -1,10 +1,42 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DeleteLocalActionButton extends ConsumerWidget { - const DeleteLocalActionButton({super.key}); + final ActionSource source; + + const DeleteLocalActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).deleteLocal(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'delete_local_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -12,6 +44,7 @@ class DeleteLocalActionButton extends ConsumerWidget { maxWidth: 95.0, iconData: Icons.no_cell_outlined, label: "control_bottom_app_bar_delete_from_local".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index a7a2a57ce5..0ad87e05e8 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -58,7 +58,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { ), ], if (asset.storage == AssetState.local) ...[ - const DeleteLocalActionButton(), + const DeleteLocalActionButton(source: ActionSource.viewer), const UploadActionButton(), ], ]; diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index c23f268465..3c21630d2e 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -52,7 +52,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const StackActionButton(), ], if (multiselect.hasLocal) ...[ - const DeleteLocalActionButton(), + const DeleteLocalActionButton(source: ActionSource.timeline), const UploadActionButton(), ], ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 2f8208a80b..d73d3c22e0 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -52,7 +52,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const StackActionButton(), ], if (multiselect.hasLocal) ...[ - const DeleteLocalActionButton(), + const DeleteLocalActionButton(source: ActionSource.timeline), const UploadActionButton(), ], ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 900adefd0b..6726f18246 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -52,7 +52,7 @@ class GeneralBottomSheet extends ConsumerWidget { const StackActionButton(), ], if (multiselect.hasLocal) ...[ - const DeleteLocalActionButton(), + const DeleteLocalActionButton(source: ActionSource.timeline), const UploadActionButton(), ], ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index 3fd717f516..d41f8ecb96 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart'; @@ -16,7 +17,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget { shouldCloseOnMinExtent: false, actions: [ ShareActionButton(), - DeleteLocalActionButton(), + DeleteLocalActionButton(source: ActionSource.timeline), UploadActionButton(), ], ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 0268a2b386..1ed662884a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -55,7 +55,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { const StackActionButton(), ], if (multiselect.hasLocal) ...[ - const DeleteLocalActionButton(), + const DeleteLocalActionButton(source: ActionSource.timeline), const UploadActionButton(), ], RemoveFromAlbumActionButton( diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 49605e918a..25b5783137 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -41,7 +41,13 @@ class ActionNotifier extends Notifier { } List _getRemoteIdsForSource(ActionSource source) { - return _getIdsForSource(source).toIds().toList(); + return _getIdsForSource(source) + .toIds() + .toList(growable: false); + } + + List _getLocalIdsForSource(ActionSource source) { + return _getIdsForSource(source).toIds().toList(growable: false); } List _getOwnedRemoteForSource(ActionSource source) { @@ -49,23 +55,22 @@ class ActionNotifier extends Notifier { return _getIdsForSource(source) .ownedAssets(ownerId) .toIds() - .toList(); + .toList(growable: false); } Iterable _getIdsForSource(ActionSource source) { final Set assets = switch (source) { - ActionSource.timeline => - ref.read(multiSelectProvider.select((s) => s.selectedAssets)), + ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { BaseAsset asset => {asset}, - null => {}, + null => const {}, }, }; return switch (T) { const (RemoteAsset) => assets.whereType(), const (LocalAsset) => assets.whereType(), - _ => [], + _ => const [], } as Iterable; } @@ -207,6 +212,21 @@ class ActionNotifier extends Notifier { } } + Future deleteLocal(ActionSource source) async { + final ids = _getLocalIdsForSource(source); + try { + await _service.deleteLocal(ids); + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to delete assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } + Future editLocation( ActionSource source, BuildContext context, @@ -252,7 +272,11 @@ extension on Iterable { Iterable toIds() => map((e) => e.id); Iterable ownedAssets(String? ownerId) { - if (ownerId == null) return []; + if (ownerId == null) return const []; return whereType().where((a) => a.ownerId == ownerId); } } + +extension on Iterable { + Iterable toIds() => map((e) => e.id); +} diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index d7c625b981..bb2e1514e8 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -2,11 +2,13 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -17,22 +19,28 @@ final actionServiceProvider = Provider( (ref) => ActionService( ref.watch(assetApiRepositoryProvider), ref.watch(remoteAssetRepositoryProvider), + ref.watch(localAssetRepository), ref.watch(driftAlbumApiRepositoryProvider), ref.watch(remoteAlbumRepository), + ref.watch(assetMediaRepositoryProvider), ), ); class ActionService { final AssetApiRepository _assetApiRepository; final RemoteAssetRepository _remoteAssetRepository; + final DriftLocalAssetRepository _localAssetRepository; final DriftAlbumApiRepository _albumApiRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository; + final AssetMediaRepository _assetMediaRepository; const ActionService( this._assetApiRepository, this._remoteAssetRepository, + this._localAssetRepository, this._albumApiRepository, this._remoteAlbumRepository, + this._assetMediaRepository, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -107,6 +115,11 @@ class ActionService { await _remoteAssetRepository.delete(remoteIds); } + Future deleteLocal(List localIds) async { + await _assetMediaRepository.deleteAll(localIds); + await _localAssetRepository.delete(localIds); + } + Future editLocation( List remoteIds, BuildContext context, From 184c7390a1ab74e133f3cebf5418b7d00d68673d Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Thu, 17 Jul 2025 04:14:58 +0200 Subject: [PATCH 10/45] chore: also redirect docs/ with trailing slash (#19965) Cause I keep running into that lol --- docs/static/_redirects | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/static/_redirects b/docs/static/_redirects index 6683c78077..7b01d1e3bb 100644 --- a/docs/static/_redirects +++ b/docs/static/_redirects @@ -1,4 +1,5 @@ -/docs /docs/overview/introduction 307 +/docs /docs/overview/welcome 307 +/docs/ /docs/overview/welcome 307 /docs/mobile-app-beta-program /docs/features/mobile-app 307 /docs/contribution-guidelines /docs/overview/support-the-project#contributing 307 /docs/install /docs/install/docker-compose 307 @@ -30,4 +31,4 @@ /docs/guides/api-album-sync /docs/community-projects 307 /docs/guides/remove-offline-files /docs/community-projects 307 /milestones /roadmap 307 -/docs/overview/introduction /docs/overview/welcome 307 \ No newline at end of file +/docs/overview/introduction /docs/overview/welcome 307 From 238dc7c0859cd6fadfa74c4f86cd019e660af423 Mon Sep 17 00:00:00 2001 From: Lucas Correia <98816628+lcs-crr@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:40:49 +0200 Subject: [PATCH 11/45] chore: adjust setting subtitle to less clumsy expression (#19970) --- i18n/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/i18n/en.json b/i18n/en.json index 175399c7ab..fb0c15d9fc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -376,7 +376,7 @@ "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter", "advanced_settings_log_level_title": "Log level: {level}", - "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", + "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from local assets. Activate this setting to load remote images instead.", "advanced_settings_prefer_remote_title": "Prefer remote images", "advanced_settings_proxy_headers_subtitle": "Define proxy headers Immich should send with each network request", "advanced_settings_proxy_headers_title": "Proxy Headers", From b256c51b6b3ed70cdf67d2fa7d9a362941c1be0a Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Thu, 17 Jul 2025 10:49:55 -0400 Subject: [PATCH 12/45] docs: update devcontainer docs (#19237) --- docs/docs/developer/devcontainers.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/docs/developer/devcontainers.md b/docs/docs/developer/devcontainers.md index 1aab5ad327..c4c5396466 100644 --- a/docs/docs/developer/devcontainers.md +++ b/docs/docs/developer/devcontainers.md @@ -7,7 +7,7 @@ sidebar_position: 3 Dev Containers provide a consistent, reproducible development environment using Docker containers. With a single click, you can get started with an Immich development environment on Mac, Linux, Windows, or in the cloud using GitHub Codespaces. -[![Open in VSCode Containers](https://img.shields.io/static/v1?label=VSCode%20DevContainer&message=Immich&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/immich-app/immich/) +Get started fast! [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/immich-app/immich/) @@ -71,7 +71,7 @@ cd immich The immich dev containers read environment variables from your shell environment, not from `.env` files. This allows them to work in cloud environments without pre-configuration. -:::important Required Configuration +:::important Configuration When running locally, and if you want to create (or use an existing) DB and/or photo storage folder, you must set the `UPLOAD_LOCATION` variable in your shell environment before launching the Dev Container. This determines where uploaded files are stored and also where the DB stores it data. ```bash @@ -88,6 +88,10 @@ source ~/.bashrc ### Step 3: Launch the Dev Container +:::tip +Immich development makes extensive use of specialized [base images](https://github.com/immich-app/base-images) for its docker-compose based development. For this reason, you won't be able to use VSCode's **_Clone Repository in a Container Volume_** command. +::: + #### Using VS Code UI: 1. Open the cloned repository in VS Code From 531515daf9c94442a75888e3b5ed4656dce90087 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:42:29 +0530 Subject: [PATCH 13/45] feat: add toggle to switch between Isar and Sqlite (#19953) --- i18n/en.json | 2 + .../drift_schemas/main/drift_schema_v2.json | 1 + mobile/lib/domain/models/store.model.dart | 4 +- mobile/lib/domain/services/store.service.dart | 2 + .../repositories/db.repository.dart | 30 +- .../repositories/db.repository.steps.dart | 864 +++ .../pages/common/change_experience.page.dart | 126 + mobile/lib/pages/common/settings.page.dart | 97 +- .../lib/pages/common/splash_screen.page.dart | 10 +- mobile/lib/pages/common/tab_shell.page.dart | 19 +- .../pages/share_intent/share_intent.page.dart | 5 +- .../providers/app_life_cycle.provider.dart | 112 +- .../providers/infrastructure/db.provider.dart | 1 + mobile/lib/repositories/auth.repository.dart | 31 +- mobile/lib/routing/router.dart | 34 +- mobile/lib/routing/router.gr.dart | 43 + mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/utils/isolate.dart | 16 +- mobile/lib/utils/migration.dart | 64 +- .../widgets/common/immich_sliver_app_bar.dart | 51 +- .../lib/widgets/forms/login/login_form.dart | 22 +- .../settings/beta_timeline_list_tile.dart | 269 + mobile/pubspec.yaml | 1 - mobile/test/drift/main/generated/schema.dart | 23 + .../test/drift/main/generated/schema_v1.dart | 4638 ++++++++++++++++ .../test/drift/main/generated/schema_v2.dart | 4672 +++++++++++++++++ mobile/test/drift/main/migration_test.dart | 38 + 27 files changed, 11016 insertions(+), 160 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v2.json create mode 100644 mobile/lib/infrastructure/repositories/db.repository.steps.dart create mode 100644 mobile/lib/pages/common/change_experience.page.dart create mode 100644 mobile/lib/widgets/settings/beta_timeline_list_tile.dart create mode 100644 mobile/test/drift/main/generated/schema.dart create mode 100644 mobile/test/drift/main/generated/schema_v1.dart create mode 100644 mobile/test/drift/main/generated/schema_v2.dart create mode 100644 mobile/test/drift/main/migration_test.dart diff --git a/i18n/en.json b/i18n/en.json index fb0c15d9fc..6cabcecf7d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -373,6 +373,8 @@ "admin_password": "Admin Password", "administration": "Administration", "advanced": "Advanced", + "advanced_settings_beta_timeline_subtitle": "Try the new app experience.", + "advanced_settings_beta_timeline_title": "Beta Timeline", "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter", "advanced_settings_log_level_title": "Log level: {level}", diff --git a/mobile/drift_schemas/main/drift_schema_v2.json b/mobile/drift_schemas/main/drift_schema_v2.json new file mode 100644 index 0000000000..4c1e3d68a1 --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v2.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":4,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":6,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":7,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":8,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":9,"references":[2,8],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":10,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":11,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":12,"references":[1,11],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":13,"references":[11,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":14,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":15,"references":[1,14],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":16,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}}]} \ No newline at end of file diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index a96e8d3bce..61c5807ba8 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -68,7 +68,9 @@ enum StoreKey { manageLocalMediaAndroid._(137), // Experimental stuff - photoManagerCustomFilter._(1000); + photoManagerCustomFilter._(1000), + betaPromptShown._(1001), + betaTimeline._(1002); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/domain/services/store.service.dart b/mobile/lib/domain/services/store.service.dart index 359ce8cf60..b1e991b348 100644 --- a/mobile/lib/domain/services/store.service.dart +++ b/mobile/lib/domain/services/store.service.dart @@ -93,6 +93,8 @@ class StoreService { await _storeRepository.deleteAll(); _cache.clear(); } + + bool get isBetaTimelineEnabled => tryGet(StoreKey.betaTimeline) ?? false; } class StoreKeyNotFoundException implements Exception { diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index a7920cf7b2..200ec47516 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; +import 'package:flutter/foundation.dart'; import 'package:immich_mobile/domain/interfaces/db.interface.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; import 'package:immich_mobile/infrastructure/entities/local_album.entity.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/stack.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.steps.dart'; import 'package:isar/isar.dart'; import 'db.repository.drift.dart'; @@ -68,10 +70,36 @@ class Drift extends $Drift implements IDatabaseRepository { ); @override - int get schemaVersion => 1; + int get schemaVersion => 2; @override MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (m, from, to) async { + // Run migration steps without foreign keys and re-enable them later + await customStatement('PRAGMA foreign_keys = OFF'); + + await m.runMigrationSteps( + from: from, + to: to, + steps: migrationSteps( + from1To2: (m, _) async { + for (final entity in allSchemaEntities) { + await m.drop(entity); + await m.create(entity); + } + }, + ), + ); + + if (kDebugMode) { + // Fail if the migration broke foreign keys + final wrongFKs = + await customSelect('PRAGMA foreign_key_check').get(); + assert(wrongFKs.isEmpty, '${wrongFKs.map((e) => e.data)}'); + } + + await customStatement('PRAGMA foreign_keys = ON;'); + }, beforeOpen: (details) async { await customStatement('PRAGMA foreign_keys = ON'); await customStatement('PRAGMA synchronous = NORMAL'); diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart new file mode 100644 index 0000000000..5815d31978 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -0,0 +1,864 @@ +// dart format width=80 +import 'package:drift/internal/versioned_schema.dart' as i0; +import 'package:drift/drift.dart' as i1; +import 'dart:typed_data' as i2; +import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import + +// GENERATED BY drift_dev, DO NOT MODIFY. +final class Schema2 extends i0.VersionedSchema { + Schema2({required super.database}) : super(version: 2); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + localAssetEntity, + idxLocalAssetChecksum, + uQRemoteAssetOwnerChecksum, + idxRemoteAssetChecksum, + userMetadataEntity, + partnerEntity, + localAlbumEntity, + localAlbumAssetEntity, + remoteExifEntity, + remoteAlbumEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + stackEntity, + ]; + late final Shape0 userEntity = Shape0( + source: i0.VersionedTable( + entityName: 'user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape1 remoteAssetEntity = Shape1( + source: i0.VersionedTable( + entityName: 'remote_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape2 localAssetEntity = Shape2( + source: i0.VersionedTable( + entityName: 'local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_21, + _column_14, + _column_22, + ], + attachedDatabase: database, + ), + alias: null); + final i1.Index idxLocalAssetChecksum = i1.Index('idx_local_asset_checksum', + 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); + final i1.Index uQRemoteAssetOwnerChecksum = i1.Index( + 'UQ_remote_asset_owner_checksum', + 'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)'); + final i1.Index idxRemoteAssetChecksum = i1.Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); + late final Shape3 userMetadataEntity = Shape3( + source: i0.VersionedTable( + entityName: 'user_metadata_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(user_id, "key")', + ], + columns: [ + _column_23, + _column_24, + _column_25, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape4 partnerEntity = Shape4( + source: i0.VersionedTable( + entityName: 'partner_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(shared_by_id, shared_with_id)', + ], + columns: [ + _column_26, + _column_27, + _column_28, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape5 localAlbumEntity = Shape5( + source: i0.VersionedTable( + entityName: 'local_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_5, + _column_29, + _column_30, + _column_31, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape6 localAlbumAssetEntity = Shape6( + source: i0.VersionedTable( + entityName: 'local_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id, album_id)', + ], + columns: [ + _column_32, + _column_33, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape7 remoteExifEntity = Shape7( + source: i0.VersionedTable( + entityName: 'remote_exif_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id)', + ], + columns: [ + _column_34, + _column_35, + _column_36, + _column_37, + _column_38, + _column_39, + _column_11, + _column_10, + _column_40, + _column_41, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + _column_52, + _column_53, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape8 remoteAlbumEntity = Shape8( + source: i0.VersionedTable( + entityName: 'remote_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_54, + _column_9, + _column_5, + _column_15, + _column_55, + _column_56, + _column_57, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape6 remoteAlbumAssetEntity = Shape6( + source: i0.VersionedTable( + entityName: 'remote_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id, album_id)', + ], + columns: [ + _column_34, + _column_58, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape9 remoteAlbumUserEntity = Shape9( + source: i0.VersionedTable( + entityName: 'remote_album_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(album_id, user_id)', + ], + columns: [ + _column_58, + _column_23, + _column_59, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape10 memoryEntity = Shape10( + source: i0.VersionedTable( + entityName: 'memory_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_18, + _column_15, + _column_8, + _column_60, + _column_61, + _column_62, + _column_63, + _column_64, + _column_65, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape11 memoryAssetEntity = Shape11( + source: i0.VersionedTable( + entityName: 'memory_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id, memory_id)', + ], + columns: [ + _column_34, + _column_66, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape12 stackEntity = Shape12( + source: i0.VersionedTable( + entityName: 'stack_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_67, + ], + attachedDatabase: database, + ), + alias: null); +} + +class Shape0 extends i0.VersionedTable { + Shape0({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get isAdmin => + columnsByName['is_admin']! as i1.GeneratedColumn; + i1.GeneratedColumn get email => + columnsByName['email']! as i1.GeneratedColumn; + i1.GeneratedColumn get profileImagePath => + columnsByName['profile_image_path']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get quotaSizeInBytes => + columnsByName['quota_size_in_bytes']! as i1.GeneratedColumn; + i1.GeneratedColumn get quotaUsageInBytes => + columnsByName['quota_usage_in_bytes']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_0(String aliasedName) => + i1.GeneratedColumn('id', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_1(String aliasedName) => + i1.GeneratedColumn('name', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_2(String aliasedName) => + i1.GeneratedColumn('is_admin', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_admin" IN (0, 1))'), + defaultValue: const CustomExpression('0')); +i1.GeneratedColumn _column_3(String aliasedName) => + i1.GeneratedColumn('email', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_4(String aliasedName) => + i1.GeneratedColumn('profile_image_path', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_5(String aliasedName) => + i1.GeneratedColumn('updated_at', aliasedName, false, + type: i1.DriftSqlType.dateTime, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); +i1.GeneratedColumn _column_6(String aliasedName) => + i1.GeneratedColumn('quota_size_in_bytes', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_7(String aliasedName) => + i1.GeneratedColumn('quota_usage_in_bytes', aliasedName, false, + type: i1.DriftSqlType.int, defaultValue: const CustomExpression('0')); + +class Shape1 extends i0.VersionedTable { + Shape1({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get width => + columnsByName['width']! as i1.GeneratedColumn; + i1.GeneratedColumn get height => + columnsByName['height']! as i1.GeneratedColumn; + i1.GeneratedColumn get durationInSeconds => + columnsByName['duration_in_seconds']! as i1.GeneratedColumn; + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get checksum => + columnsByName['checksum']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get ownerId => + columnsByName['owner_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get localDateTime => + columnsByName['local_date_time']! as i1.GeneratedColumn; + i1.GeneratedColumn get thumbHash => + columnsByName['thumb_hash']! as i1.GeneratedColumn; + i1.GeneratedColumn get deletedAt => + columnsByName['deleted_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get livePhotoVideoId => + columnsByName['live_photo_video_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get visibility => + columnsByName['visibility']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_8(String aliasedName) => + i1.GeneratedColumn('type', aliasedName, false, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_9(String aliasedName) => + i1.GeneratedColumn('created_at', aliasedName, false, + type: i1.DriftSqlType.dateTime, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); +i1.GeneratedColumn _column_10(String aliasedName) => + i1.GeneratedColumn('width', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_11(String aliasedName) => + i1.GeneratedColumn('height', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_12(String aliasedName) => + i1.GeneratedColumn('duration_in_seconds', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_13(String aliasedName) => + i1.GeneratedColumn('checksum', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_14(String aliasedName) => + i1.GeneratedColumn('is_favorite', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn('owner_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); +i1.GeneratedColumn _column_16(String aliasedName) => + i1.GeneratedColumn('local_date_time', aliasedName, true, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_17(String aliasedName) => + i1.GeneratedColumn('thumb_hash', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_18(String aliasedName) => + i1.GeneratedColumn('deleted_at', aliasedName, true, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_19(String aliasedName) => + i1.GeneratedColumn('live_photo_video_id', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_20(String aliasedName) => + i1.GeneratedColumn('visibility', aliasedName, false, + type: i1.DriftSqlType.int); + +class Shape2 extends i0.VersionedTable { + Shape2({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get width => + columnsByName['width']! as i1.GeneratedColumn; + i1.GeneratedColumn get height => + columnsByName['height']! as i1.GeneratedColumn; + i1.GeneratedColumn get durationInSeconds => + columnsByName['duration_in_seconds']! as i1.GeneratedColumn; + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get checksum => + columnsByName['checksum']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get orientation => + columnsByName['orientation']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_21(String aliasedName) => + i1.GeneratedColumn('checksum', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_22(String aliasedName) => + i1.GeneratedColumn('orientation', aliasedName, false, + type: i1.DriftSqlType.int, defaultValue: const CustomExpression('0')); + +class Shape3 extends i0.VersionedTable { + Shape3({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get userId => + columnsByName['user_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get key => + columnsByName['key']! as i1.GeneratedColumn; + i1.GeneratedColumn get value => + columnsByName['value']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_23(String aliasedName) => + i1.GeneratedColumn('user_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); +i1.GeneratedColumn _column_24(String aliasedName) => + i1.GeneratedColumn('key', aliasedName, false, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_25(String aliasedName) => + i1.GeneratedColumn('value', aliasedName, false, + type: i1.DriftSqlType.blob); + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get sharedById => + columnsByName['shared_by_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get sharedWithId => + columnsByName['shared_with_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get inTimeline => + columnsByName['in_timeline']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_26(String aliasedName) => + i1.GeneratedColumn('shared_by_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); +i1.GeneratedColumn _column_27(String aliasedName) => + i1.GeneratedColumn('shared_with_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); +i1.GeneratedColumn _column_28(String aliasedName) => + i1.GeneratedColumn('in_timeline', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("in_timeline" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get backupSelection => + columnsByName['backup_selection']! as i1.GeneratedColumn; + i1.GeneratedColumn get isIosSharedAlbum => + columnsByName['is_ios_shared_album']! as i1.GeneratedColumn; + i1.GeneratedColumn get marker_ => + columnsByName['marker']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_29(String aliasedName) => + i1.GeneratedColumn('backup_selection', aliasedName, false, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_30(String aliasedName) => + i1.GeneratedColumn('is_ios_shared_album', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))'), + defaultValue: const CustomExpression('0')); +i1.GeneratedColumn _column_31(String aliasedName) => + i1.GeneratedColumn('marker', aliasedName, true, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("marker" IN (0, 1))')); + +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get assetId => + columnsByName['asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get albumId => + columnsByName['album_id']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_32(String aliasedName) => + i1.GeneratedColumn('asset_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE')); +i1.GeneratedColumn _column_33(String aliasedName) => + i1.GeneratedColumn('album_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE')); + +class Shape7 extends i0.VersionedTable { + Shape7({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get assetId => + columnsByName['asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get city => + columnsByName['city']! as i1.GeneratedColumn; + i1.GeneratedColumn get state => + columnsByName['state']! as i1.GeneratedColumn; + i1.GeneratedColumn get country => + columnsByName['country']! as i1.GeneratedColumn; + i1.GeneratedColumn get dateTimeOriginal => + columnsByName['date_time_original']! as i1.GeneratedColumn; + i1.GeneratedColumn get description => + columnsByName['description']! as i1.GeneratedColumn; + i1.GeneratedColumn get height => + columnsByName['height']! as i1.GeneratedColumn; + i1.GeneratedColumn get width => + columnsByName['width']! as i1.GeneratedColumn; + i1.GeneratedColumn get exposureTime => + columnsByName['exposure_time']! as i1.GeneratedColumn; + i1.GeneratedColumn get fNumber => + columnsByName['f_number']! as i1.GeneratedColumn; + i1.GeneratedColumn get fileSize => + columnsByName['file_size']! as i1.GeneratedColumn; + i1.GeneratedColumn get focalLength => + columnsByName['focal_length']! as i1.GeneratedColumn; + i1.GeneratedColumn get latitude => + columnsByName['latitude']! as i1.GeneratedColumn; + i1.GeneratedColumn get longitude => + columnsByName['longitude']! as i1.GeneratedColumn; + i1.GeneratedColumn get iso => + columnsByName['iso']! as i1.GeneratedColumn; + i1.GeneratedColumn get make => + columnsByName['make']! as i1.GeneratedColumn; + i1.GeneratedColumn get model => + columnsByName['model']! as i1.GeneratedColumn; + i1.GeneratedColumn get lens => + columnsByName['lens']! as i1.GeneratedColumn; + i1.GeneratedColumn get orientation => + columnsByName['orientation']! as i1.GeneratedColumn; + i1.GeneratedColumn get timeZone => + columnsByName['time_zone']! as i1.GeneratedColumn; + i1.GeneratedColumn get rating => + columnsByName['rating']! as i1.GeneratedColumn; + i1.GeneratedColumn get projectionType => + columnsByName['projection_type']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_34(String aliasedName) => + i1.GeneratedColumn('asset_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); +i1.GeneratedColumn _column_35(String aliasedName) => + i1.GeneratedColumn('city', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_36(String aliasedName) => + i1.GeneratedColumn('state', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_37(String aliasedName) => + i1.GeneratedColumn('country', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_38(String aliasedName) => + i1.GeneratedColumn('date_time_original', aliasedName, true, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_39(String aliasedName) => + i1.GeneratedColumn('description', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_40(String aliasedName) => + i1.GeneratedColumn('exposure_time', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_41(String aliasedName) => + i1.GeneratedColumn('f_number', aliasedName, true, + type: i1.DriftSqlType.double); +i1.GeneratedColumn _column_42(String aliasedName) => + i1.GeneratedColumn('file_size', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_43(String aliasedName) => + i1.GeneratedColumn('focal_length', aliasedName, true, + type: i1.DriftSqlType.double); +i1.GeneratedColumn _column_44(String aliasedName) => + i1.GeneratedColumn('latitude', aliasedName, true, + type: i1.DriftSqlType.double); +i1.GeneratedColumn _column_45(String aliasedName) => + i1.GeneratedColumn('longitude', aliasedName, true, + type: i1.DriftSqlType.double); +i1.GeneratedColumn _column_46(String aliasedName) => + i1.GeneratedColumn('iso', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_47(String aliasedName) => + i1.GeneratedColumn('make', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_48(String aliasedName) => + i1.GeneratedColumn('model', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_49(String aliasedName) => + i1.GeneratedColumn('lens', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_50(String aliasedName) => + i1.GeneratedColumn('orientation', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_51(String aliasedName) => + i1.GeneratedColumn('time_zone', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_52(String aliasedName) => + i1.GeneratedColumn('rating', aliasedName, true, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_53(String aliasedName) => + i1.GeneratedColumn('projection_type', aliasedName, true, + type: i1.DriftSqlType.string); + +class Shape8 extends i0.VersionedTable { + Shape8({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get description => + columnsByName['description']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get ownerId => + columnsByName['owner_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get thumbnailAssetId => + columnsByName['thumbnail_asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get isActivityEnabled => + columnsByName['is_activity_enabled']! as i1.GeneratedColumn; + i1.GeneratedColumn get order => + columnsByName['order']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_54(String aliasedName) => + i1.GeneratedColumn('description', aliasedName, false, + type: i1.DriftSqlType.string, + defaultValue: const CustomExpression('\'\'')); +i1.GeneratedColumn _column_55(String aliasedName) => + i1.GeneratedColumn('thumbnail_asset_id', aliasedName, true, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL')); +i1.GeneratedColumn _column_56(String aliasedName) => + i1.GeneratedColumn('is_activity_enabled', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))'), + defaultValue: const CustomExpression('1')); +i1.GeneratedColumn _column_57(String aliasedName) => + i1.GeneratedColumn('order', aliasedName, false, + type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_58(String aliasedName) => + i1.GeneratedColumn('album_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + +class Shape9 extends i0.VersionedTable { + Shape9({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get albumId => + columnsByName['album_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get userId => + columnsByName['user_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get role => + columnsByName['role']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_59(String aliasedName) => + i1.GeneratedColumn('role', aliasedName, false, + type: i1.DriftSqlType.int); + +class Shape10 extends i0.VersionedTable { + Shape10({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get deletedAt => + columnsByName['deleted_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get ownerId => + columnsByName['owner_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get type => + columnsByName['type']! as i1.GeneratedColumn; + i1.GeneratedColumn get data => + columnsByName['data']! as i1.GeneratedColumn; + i1.GeneratedColumn get isSaved => + columnsByName['is_saved']! as i1.GeneratedColumn; + i1.GeneratedColumn get memoryAt => + columnsByName['memory_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get seenAt => + columnsByName['seen_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get showAt => + columnsByName['show_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get hideAt => + columnsByName['hide_at']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_60(String aliasedName) => + i1.GeneratedColumn('data', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_61(String aliasedName) => + i1.GeneratedColumn('is_saved', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_saved" IN (0, 1))'), + defaultValue: const CustomExpression('0')); +i1.GeneratedColumn _column_62(String aliasedName) => + i1.GeneratedColumn('memory_at', aliasedName, false, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_63(String aliasedName) => + i1.GeneratedColumn('seen_at', aliasedName, true, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_64(String aliasedName) => + i1.GeneratedColumn('show_at', aliasedName, true, + type: i1.DriftSqlType.dateTime); +i1.GeneratedColumn _column_65(String aliasedName) => + i1.GeneratedColumn('hide_at', aliasedName, true, + type: i1.DriftSqlType.dateTime); + +class Shape11 extends i0.VersionedTable { + Shape11({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get assetId => + columnsByName['asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get memoryId => + columnsByName['memory_id']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_66(String aliasedName) => + i1.GeneratedColumn('memory_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE')); + +class Shape12 extends i0.VersionedTable { + Shape12({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get ownerId => + columnsByName['owner_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get primaryAssetId => + columnsByName['primary_asset_id']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_67(String aliasedName) => + i1.GeneratedColumn('primary_asset_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id)')); +i0.MigrationStepWithVersion migrationSteps({ + required Future Function(i1.Migrator m, Schema2 schema) from1To2, +}) { + return (currentVersion, database) async { + switch (currentVersion) { + case 1: + final schema = Schema2(database: database); + final migrator = i1.Migrator(database, schema); + await from1To2(migrator, schema); + return 2; + default: + throw ArgumentError.value('Unknown migration from $currentVersion'); + } + }; +} + +i1.OnUpgrade stepByStep({ + required Future Function(i1.Migrator m, Schema2 schema) from1To2, +}) => + i0.VersionedSchema.stepByStepHelper( + step: migrationSteps( + from1To2: from1To2, + )); diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart new file mode 100644 index 0000000000..c54f627b61 --- /dev/null +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -0,0 +1,126 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/migration.dart'; +import 'package:permission_handler/permission_handler.dart'; + +@RoutePage() +class ChangeExperiencePage extends ConsumerStatefulWidget { + final bool switchingToBeta; + + const ChangeExperiencePage({super.key, required this.switchingToBeta}); + + @override + ConsumerState createState() => _ChangeExperiencePageState(); +} + +class _ChangeExperiencePageState extends ConsumerState { + bool hasMigrated = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _handleMigration()); + } + + Future _handleMigration() async { + if (widget.switchingToBeta) { + final assetNotifier = ref.read(assetProvider.notifier); + if (assetNotifier.mounted) { + assetNotifier.dispose(); + } + final albumNotifier = ref.read(albumProvider.notifier); + if (albumNotifier.mounted) { + albumNotifier.dispose(); + } + + final permission = await ref + .read(galleryPermissionNotifier.notifier) + .requestGalleryPermission(); + + if (permission.isGranted) { + await ref.read(backgroundSyncProvider).syncLocal(full: true); + await migrateDeviceAssetToSqlite( + ref.read(isarProvider), + ref.read(driftProvider), + ); + } + } else { + await ref.read(backgroundSyncProvider).cancel(); + } + + Future.delayed(const Duration(seconds: 3), () { + context.replaceRoute( + widget.switchingToBeta + ? const TabShellRoute() + : const TabControllerRoute(), + ); + }); + + if (mounted) { + setState(() { + HapticFeedback.heavyImpact(); + hasMigrated = true; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: Durations.long4, + child: hasMigrated + ? const Icon( + Icons.check_circle_rounded, + color: Colors.green, + size: 48.0, + ) + : const SizedBox( + width: 50.0, + height: 50.0, + child: CircularProgressIndicator(), + ), + ), + const SizedBox(height: 16.0), + Center( + child: Column( + children: [ + SizedBox( + width: 300.0, + child: AnimatedSwitcher( + duration: Durations.long4, + child: hasMigrated + ? Text( + "Migration success. Navigating to the new timeline...", + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, + ) + : Text( + "Data migration in progress...\nPlease wait and don't close this page", + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 6fc4cf5d88..42d555a2ee 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/widgets/settings/advanced_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart'; import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart'; +import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart'; import 'package:immich_mobile/widgets/settings/language_settings.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/networking_settings.dart'; import 'package:immich_mobile/widgets/settings/notification_setting.dart'; @@ -94,55 +95,59 @@ class _MobileLayout extends StatelessWidget { const _MobileLayout(); @override Widget build(BuildContext context) { + final List settings = SettingSection.values + .map( + (setting) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Card( + elevation: 0, + clipBehavior: Clip.antiAlias, + color: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + margin: const EdgeInsets.symmetric(vertical: 4.0), + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + leading: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: context.isDarkTheme + ? Colors.black26 + : Colors.white.withAlpha(100), + ), + padding: const EdgeInsets.all(16.0), + child: Icon(setting.icon, color: context.primaryColor), + ), + title: Text( + setting.title, + style: context.textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ).tr(), + subtitle: Text( + setting.subtitle, + style: context.textTheme.labelLarge, + ).tr(), + onTap: () => + context.pushRoute(SettingsSubRoute(section: setting)), + ), + ), + ), + ) + .toList(); return ListView( physics: const ClampingScrollPhysics(), padding: const EdgeInsets.symmetric(vertical: 10.0), - children: SettingSection.values - .map( - (setting) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - child: Card( - elevation: 0, - clipBehavior: Clip.antiAlias, - color: context.colorScheme.surfaceContainer, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(16)), - ), - margin: const EdgeInsets.symmetric(vertical: 4.0), - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - ), - leading: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(16)), - color: context.isDarkTheme - ? Colors.black26 - : Colors.white.withAlpha(100), - ), - padding: const EdgeInsets.all(16.0), - child: Icon(setting.icon, color: context.primaryColor), - ), - title: Text( - setting.title, - style: context.textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.w600, - color: context.primaryColor, - ), - ).tr(), - subtitle: Text( - setting.subtitle, - style: context.textTheme.labelLarge, - ).tr(), - onTap: () => - context.pushRoute(SettingsSubRoute(section: setting)), - ), - ), - ), - ) - .toList(), + children: [ + const BetaTimelineListTile(), + ...settings, + ], ); } } diff --git a/mobile/lib/pages/common/splash_screen.page.dart b/mobile/lib/pages/common/splash_screen.page.dart index 4b7a10d612..598e920651 100644 --- a/mobile/lib/pages/common/splash_screen.page.dart +++ b/mobile/lib/pages/common/splash_screen.page.dart @@ -73,7 +73,15 @@ class SplashScreenPageState extends ConsumerState { } if (context.router.current.name == SplashScreenRoute.name) { - context.replaceRoute(const TabControllerRoute()); + context.replaceRoute( + Store.isBetaTimelineEnabled + ? const TabShellRoute() + : const TabControllerRoute(), + ); + } + + if (Store.isBetaTimelineEnabled) { + return; } final hasPermission = diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index edaec6d336..c8ca7ea061 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -10,13 +10,28 @@ import 'package:immich_mobile/providers/search/search_input_focus.provider.dart' import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/migration.dart'; @RoutePage() -class TabShellPage extends ConsumerWidget { +class TabShellPage extends ConsumerStatefulWidget { const TabShellPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _TabShellPageState(); +} + +class _TabShellPageState extends ConsumerState { + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + runNewSync(ref, full: true); + }); + } + + @override + Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; Widget buildIcon({required Widget icon, required bool isProcessing}) { diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 3ff1b0c8ce..11c114e4a6 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -3,6 +3,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart'; import 'package:immich_mobile/pages/common/large_leading_tile.dart'; @@ -75,7 +76,9 @@ class ShareIntentPage extends HookConsumerWidget { leading: IconButton( onPressed: () { context.navigateTo( - const TabControllerRoute(), + Store.isBetaTimelineEnabled + ? const TabShellRoute() + : const TabControllerRoute(), ); }, icon: const Icon(Icons.arrow_back), diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 3ec7813e2f..b4b7b0d406 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; @@ -18,6 +20,7 @@ import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; enum AppLifeCycleEnum { @@ -57,29 +60,59 @@ class AppLifeCycleNotifier extends StateNotifier { debugPrint("Using server URL: $endpoint"); } - final permission = _ref.watch(galleryPermissionNotifier); - if (permission.isGranted || permission.isLimited) { - await _ref.read(backupProvider.notifier).resumeBackup(); - await _ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + if (!Store.isBetaTimelineEnabled) { + final permission = _ref.watch(galleryPermissionNotifier); + if (permission.isGranted || permission.isLimited) { + await _ref.read(backupProvider.notifier).resumeBackup(); + await _ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + } } await _ref.read(serverInfoProvider.notifier).getServerVersion(); } - switch (_ref.read(tabProvider)) { - case TabEnum.home: - await _ref.read(assetProvider.notifier).getAllAsset(); - break; - case TabEnum.search: - // nothing to do - break; + if (!Store.isBetaTimelineEnabled) { + switch (_ref.read(tabProvider)) { + case TabEnum.home: + await _ref.read(assetProvider.notifier).getAllAsset(); + break; + case TabEnum.search: + // nothing to do + break; - case TabEnum.albums: - await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); - break; - case TabEnum.library: - // nothing to do - break; + case TabEnum.albums: + await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); + break; + case TabEnum.library: + // nothing to do + break; + } + } else { + _ref.read(backupProvider.notifier).cancelBackup(); + + final backgroundManager = _ref.read(backgroundSyncProvider); + // Ensure proper cleanup before starting new background tasks + try { + await Future.wait([ + backgroundManager.syncLocal().then( + (_) { + Logger("AppLifeCycleNotifier") + .fine("Hashing assets after syncLocal"); + // Check if app is still active before hashing + if (state == AppLifeCycleEnum.resumed) { + backgroundManager.hashAssets(); + } + }, + ), + backgroundManager.syncRemote(), + ]); + } catch (e, stackTrace) { + Logger("AppLifeCycleNotifier").severe( + "Error during background sync", + e, + stackTrace, + ); + } } _ref.read(websocketProvider.notifier).connect(); @@ -92,9 +125,11 @@ class AppLifeCycleNotifier extends StateNotifier { .read(galleryPermissionNotifier.notifier) .getGalleryPermissionStatus(); - await _ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); + if (!Store.isBetaTimelineEnabled) { + await _ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); - _ref.invalidate(memoryFutureProvider); + _ref.invalidate(memoryFutureProvider); + } } void handleAppInactivity() { @@ -106,7 +141,8 @@ class AppLifeCycleNotifier extends StateNotifier { state = AppLifeCycleEnum.paused; _wasPaused = true; - if (_ref.read(authProvider).isAuthenticated) { + if (!Store.isBetaTimelineEnabled && + _ref.read(authProvider).isAuthenticated) { // Do not cancel backup if manual upload is in progress if (_ref.read(backupProvider.notifier).backupProgress != BackUpProgressEnum.manualInProgress) { @@ -115,15 +151,43 @@ class AppLifeCycleNotifier extends StateNotifier { _ref.read(websocketProvider.notifier).disconnect(); } - LogService.I.flush(); + try { + LogService.I.flush(); + } catch (e) { + // Ignore flush errors during pause + } } Future handleAppDetached() async { state = AppLifeCycleEnum.detached; - LogService.I.flush(); - await Isar.getInstance()?.close(); + + // Flush logs before closing database + try { + LogService.I.flush(); + } catch (e) { + // Ignore flush errors during shutdown + } + + // Close Isar database safely + try { + final isar = Isar.getInstance(); + if (isar != null && isar.isOpen) { + await isar.close(); + } + } catch (e) { + // Ignore close errors during shutdown + } + + if (Store.isBetaTimelineEnabled) { + return; + } + // no guarantee this is called at all - _ref.read(manualUploadProvider.notifier).cancelBackup(); + try { + _ref.read(manualUploadProvider.notifier).cancelBackup(); + } catch (e) { + // Ignore errors during shutdown + } } void handleAppHidden() { diff --git a/mobile/lib/providers/infrastructure/db.provider.dart b/mobile/lib/providers/infrastructure/db.provider.dart index 4eefbc556c..cdf934e508 100644 --- a/mobile/lib/providers/infrastructure/db.provider.dart +++ b/mobile/lib/providers/infrastructure/db.provider.dart @@ -13,5 +13,6 @@ Isar isar(Ref ref) => throw UnimplementedError('isar'); final driftProvider = Provider((ref) { final drift = Drift(); ref.onDispose(() => unawaited(drift.close())); + ref.keepAlive(); return drift; }); diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index 4ee4d8c131..eb86e1809f 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -24,7 +24,25 @@ class AuthRepository extends DatabaseRepository { const AuthRepository(super.db, this._drift); - Future clearLocalData() { + Future clearLocalData() async { + // Drift deletions - child entities first (those with foreign keys) + await Future.wait([ + _drift.memoryAssetEntity.deleteAll(), + _drift.remoteAlbumAssetEntity.deleteAll(), + _drift.remoteAlbumUserEntity.deleteAll(), + _drift.remoteExifEntity.deleteAll(), + _drift.userMetadataEntity.deleteAll(), + _drift.partnerEntity.deleteAll(), + _drift.stackEntity.deleteAll(), + ]); + // Drift deletions - parent entities + await Future.wait([ + _drift.memoryEntity.deleteAll(), + _drift.remoteAlbumEntity.deleteAll(), + _drift.remoteAssetEntity.deleteAll(), + _drift.userEntity.deleteAll(), + ]); + return db.writeTxn(() { return Future.wait([ db.assets.clear(), @@ -32,17 +50,6 @@ class AuthRepository extends DatabaseRepository { db.albums.clear(), db.eTags.clear(), db.users.clear(), - _drift.remoteAssetEntity.deleteAll(), - _drift.remoteExifEntity.deleteAll(), - _drift.userEntity.deleteAll(), - _drift.userMetadataEntity.deleteAll(), - _drift.partnerEntity.deleteAll(), - _drift.remoteAlbumEntity.deleteAll(), - _drift.remoteAlbumAssetEntity.deleteAll(), - _drift.remoteAlbumUserEntity.deleteAll(), - _drift.memoryEntity.deleteAll(), - _drift.memoryAssetEntity.deleteAll(), - _drift.stackEntity.deleteAll(), ]); }); } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index ddd630727d..a6666ec6eb 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -29,6 +29,7 @@ import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; import 'package:immich_mobile/pages/common/app_log_detail.page.dart'; +import 'package:immich_mobile/pages/common/change_experience.page.dart'; import 'package:immich_mobile/pages/common/create_album.page.dart'; import 'package:immich_mobile/pages/common/gallery_viewer.page.dart'; import 'package:immich_mobile/pages/common/headers_settings.page.dart'; @@ -69,27 +70,27 @@ import 'package:immich_mobile/pages/search/person_result.page.dart'; import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_archive.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart'; -import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; -import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; -import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; -import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_library.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart'; -import 'package:immich_mobile/presentation/pages/drift_memory.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; +import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -103,7 +104,6 @@ import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/local_auth.service.dart'; import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; - import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; @@ -469,6 +469,10 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: ChangeExperienceRoute.page, + guards: [_authGuard, _duplicateGuard], + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index a8a26836c7..7412e1dee5 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -503,6 +503,49 @@ class BackupOptionsRoute extends PageRouteInfo { ); } +/// generated route for +/// [ChangeExperiencePage] +class ChangeExperienceRoute extends PageRouteInfo { + ChangeExperienceRoute({ + Key? key, + required bool switchingToBeta, + List? children, + }) : super( + ChangeExperienceRoute.name, + args: ChangeExperienceRouteArgs( + key: key, + switchingToBeta: switchingToBeta, + ), + initialChildren: children, + ); + + static const String name = 'ChangeExperienceRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return ChangeExperiencePage( + key: args.key, + switchingToBeta: args.switchingToBeta, + ); + }, + ); +} + +class ChangeExperienceRouteArgs { + const ChangeExperienceRouteArgs({this.key, required this.switchingToBeta}); + + final Key? key; + + final bool switchingToBeta; + + @override + String toString() { + return 'ChangeExperienceRouteArgs{key: $key, switchingToBeta: $switchingToBeta}'; + } +} + /// generated route for /// [ChangePasswordPage] class ChangePasswordRoute extends PageRouteInfo { diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 8d1f7c24a5..982dab1cae 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -90,6 +90,7 @@ enum AppSettingsEnum { null, true, ), + betaTimeline(StoreKey.betaTimeline, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 6b20fa7f37..2263bf3d76 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:ui'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/log.service.dart'; @@ -59,9 +60,18 @@ Cancelable runInIsolateGentle({ stack, ); } finally { - await LogService.I.flushBuffer(); - ref.read(driftProvider).close(); - ref.read(isarProvider).close(); + try { + await LogService.I.flushBuffer(); + await ref.read(driftProvider).close(); + await ref.read(isarProvider).close(); + ref.dispose(); + } catch (error) { + debugPrint("Error closing resources in isolate: $error"); + } finally { + ref.dispose(); + // Delay to ensure all resources are released + await Future.delayed(const Duration(seconds: 2)); + } } return null; }); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 3ec8ce5bbc..d09f2b9978 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -18,12 +19,15 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.d import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/background_sync.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/utils/diff.dart'; import 'package:isar/isar.dart'; +import 'package:logging/logging.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 13; +const int targetVersion = 14; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, targetVersion); @@ -48,18 +52,22 @@ Future migrateDatabaseIfNeeded(Isar db) async { await _migrateDeviceAsset(db); } - if (version < 12 && (!kReleaseMode)) { + if (version < 13) { + await Store.put(StoreKey.photoManagerCustomFilter, true); + } + + if (version < 14) { + if (!Store.isBetaTimelineEnabled) { + // Try again when beta timeline is enabled and the app is restarted + return; + } final backgroundSync = BackgroundSyncManager(); await backgroundSync.syncLocal(); final drift = Drift(); - await _migrateDeviceAssetToSqlite(db, drift); + await migrateDeviceAssetToSqlite(db, drift); await drift.close(); } - if (version < 13) { - await Store.put(StoreKey.photoManagerCustomFilter, true); - } - if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -175,33 +183,24 @@ Future _migrateDeviceAsset(Isar db) async { }); } -Future _migrateDeviceAssetToSqlite(Isar db, Drift drift) async { +Future migrateDeviceAssetToSqlite(Isar db, Drift drift) async { try { - final isarDeviceAssets = - await db.deviceAssetEntitys.where().sortByAssetId().findAll(); + final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll(); await drift.batch((batch) { for (final deviceAsset in isarDeviceAssets) { - final companion = LocalAssetEntityCompanion( - updatedAt: Value(deviceAsset.modifiedTime), - id: Value(deviceAsset.assetId), - checksum: Value(base64.encode(deviceAsset.hash)), - ); - batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>( + batch.update( drift.localAssetEntity, - companion, - onConflict: DoUpdate( - (_) => companion, - where: (old) => old.updatedAt.equals(deviceAsset.modifiedTime), + LocalAssetEntityCompanion( + checksum: Value(base64.encode(deviceAsset.hash)), ), + where: (t) => t.id.equals(deviceAsset.assetId), ); } }); } catch (error) { - if (kDebugMode) { - debugPrint( - "[MIGRATION] Error while migrating device assets to SQLite: $error", - ); - } + debugPrint( + "[MIGRATION] Error while migrating device assets to SQLite: $error", + ); } } @@ -212,3 +211,18 @@ class _DeviceAsset { const _DeviceAsset({required this.assetId, this.hash, this.dateTime}); } + +Future runNewSync(WidgetRef ref, {bool full = false}) async { + ref.read(backupProvider.notifier).cancelBackup(); + + final backgroundManager = ref.read(backgroundSyncProvider); + Future.wait([ + backgroundManager.syncLocal(full: full).then( + (_) { + Logger("runNewSync").fine("Hashing assets after syncLocal"); + backgroundManager.hashAssets(); + }, + ), + backgroundManager.syncRemote(), + ]); +} diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index c2c5b79753..5ab60c913c 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/models/server_info/server_info.model.dart'; -import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; @@ -51,7 +50,6 @@ class ImmichSliverAppBar extends ConsumerWidget { pinned: pinned, snap: snap, expandedHeight: expandedHeight, - backgroundColor: context.colorScheme.surfaceContainer, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all( Radius.circular(5), @@ -68,24 +66,6 @@ class ImmichSliverAppBar extends ConsumerWidget { child: action, ), ), - IconButton( - icon: const Icon(Icons.swipe_left_alt_rounded), - onPressed: () => context.pop(), - ), - IconButton( - onPressed: () { - ref.read(backgroundSyncProvider).syncLocal(full: true); - ref.read(backgroundSyncProvider).syncRemote(); - - Future.delayed( - const Duration(seconds: 10), - () => ref.read(backgroundSyncProvider).hashAssets(), - ); - }, - icon: const Icon( - Icons.sync, - ), - ), if (isCasting) Padding( padding: const EdgeInsets.only(right: 12), @@ -127,13 +107,30 @@ class _ImmichLogoWithText extends StatelessWidget { children: [ Builder( builder: (context) { - return Padding( - padding: const EdgeInsets.only(top: 3.0), - child: SvgPicture.asset( - context.isDarkTheme - ? 'assets/immich-logo-inline-dark.svg' - : 'assets/immich-logo-inline-light.svg', - height: 40, + return Badge( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + backgroundColor: context.primaryColor, + alignment: Alignment.centerRight, + offset: const Offset(16, -8), + label: Text( + 'β', + style: TextStyle( + fontSize: 11, + color: context.colorScheme.onPrimary, + fontWeight: FontWeight.bold, + fontFamily: 'OverpassMono', + height: 1.2, + ), + ), + child: Padding( + padding: const EdgeInsets.only(top: 3.0), + child: SvgPicture.asset( + context.isDarkTheme + ? 'assets/immich-logo-inline-dark.svg' + : 'assets/immich-logo-inline-light.svg', + height: 40, + ), ), ); }, diff --git a/mobile/lib/widgets/forms/login/login_form.dart b/mobile/lib/widgets/forms/login/login_form.dart index 8ccd69930a..24a73b2cbc 100644 --- a/mobile/lib/widgets/forms/login/login_form.dart +++ b/mobile/lib/widgets/forms/login/login_form.dart @@ -10,6 +10,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; @@ -17,6 +18,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/oauth.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/migration.dart'; import 'package:immich_mobile/utils/provider_utils.dart'; import 'package:immich_mobile/utils/url_helper.dart'; import 'package:immich_mobile/utils/version_compatibility.dart'; @@ -192,6 +194,15 @@ class LoginForm extends HookConsumerWidget { if (result.shouldChangePassword && !result.isAdmin) { context.pushRoute(const ChangePasswordRoute()); } else { + final isBeta = Store.isBetaTimelineEnabled; + if (isBeta) { + await ref + .read(galleryPermissionNotifier.notifier) + .requestGalleryPermission(); + await runNewSync(ref); + context.replaceRoute(const TabShellRoute()); + return; + } context.replaceRoute(const TabControllerRoute()); } } catch (error) { @@ -292,9 +303,18 @@ class LoginForm extends HookConsumerWidget { if (isSuccess) { isLoading.value = false; final permission = ref.watch(galleryPermissionNotifier); - if (permission.isGranted || permission.isLimited) { + final isBeta = Store.isBetaTimelineEnabled; + if (!isBeta && (permission.isGranted || permission.isLimited)) { ref.watch(backupProvider.notifier).resumeBackup(); } + if (isBeta) { + await ref + .read(galleryPermissionNotifier.notifier) + .requestGalleryPermission(); + await runNewSync(ref); + context.replaceRoute(const TabShellRoute()); + return; + } context.replaceRoute(const TabControllerRoute()); } } catch (error, stack) { diff --git a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart new file mode 100644 index 0000000000..967c7f3e13 --- /dev/null +++ b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart @@ -0,0 +1,269 @@ +import 'dart:math' as math; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +class BetaTimelineListTile extends ConsumerStatefulWidget { + const BetaTimelineListTile({ + super.key, + }); + + @override + ConsumerState createState() => + _BetaTimelineListTileState(); +} + +class _BetaTimelineListTileState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _rotationAnimation; + late Animation _pulseAnimation; + late Animation _gradientAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(seconds: 3), + vsync: this, + ); + + _rotationAnimation = Tween(begin: 0, end: 2 * math.pi).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.linear, + ), + ); + + _pulseAnimation = Tween(begin: 1, end: 1.1).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + + _gradientAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + + _animationController.repeat(reverse: true); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final betaTimelineValue = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.betaTimeline); + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + void onSwitchChanged(bool value) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: value + ? const Text("Enable Beta Timeline") + : const Text("Disable Beta Timeline"), + content: value + ? const Text( + "Are you sure you want to enable the beta timeline?", + ) + : const Text( + "Are you sure you want to disable the beta timeline?", + ), + actions: [ + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + await ref.read(appSettingsServiceProvider).setSetting( + AppSettingsEnum.betaTimeline, + value, + ); + context.router.replaceAll( + [ChangeExperienceRoute(switchingToBeta: value)], + ); + }, + child: const Text("Yes"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + ], + ); + }, + ); + } + + final gradientColors = [ + Color.lerp( + context.primaryColor.withValues(alpha: 0.3), + context.primaryColor.withValues(alpha: 0.1), + _gradientAnimation.value, + )!, + Color.lerp( + context.primaryColor.withValues(alpha: 0.2), + context.primaryColor.withValues(alpha: 0.4), + _gradientAnimation.value, + )!, + Color.lerp( + context.primaryColor.withValues(alpha: 0.1), + context.primaryColor.withValues(alpha: 0.3), + _gradientAnimation.value, + )!, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(12)), + gradient: LinearGradient( + colors: gradientColors, + stops: const [0.0, 0.5, 1.0], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + transform: GradientRotation(_rotationAnimation.value * 0.1), + ), + boxShadow: [ + BoxShadow( + color: context.primaryColor.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Container( + margin: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10.5)), + color: context.scaffoldBackgroundColor, + ), + child: Material( + color: Colors.transparent, + borderRadius: const BorderRadius.all(Radius.circular(10.5)), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(10.5)), + onTap: () => onSwitchChanged(!betaTimelineValue), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Transform.scale( + scale: _pulseAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value * 0.02, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + context.primaryColor.withValues(alpha: 0.2), + context.primaryColor.withValues(alpha: 0.1), + ], + ), + ), + child: Icon( + Icons.auto_awesome, + color: context.primaryColor, + size: 20, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "advanced_settings_beta_timeline_title" + .t(context: context), + style: + context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + gradient: LinearGradient( + colors: [ + context.primaryColor + .withValues(alpha: 0.8), + context.primaryColor + .withValues(alpha: 0.6), + ], + ), + ), + child: Text( + 'NEW', + style: + context.textTheme.labelSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 10, + height: 1.2, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + "advanced_settings_beta_timeline_subtitle" + .t(context: context), + style: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color + ?.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + Switch.adaptive( + value: betaTimelineValue, + onChanged: onSwitchChanged, + activeColor: context.primaryColor, + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f8059d7e3c..bbffb9f51b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -139,7 +139,6 @@ flutter: - family: OverpassMono fonts: - asset: fonts/overpass/OverpassMono.ttf - flutter_launcher_icons: image_path_android: 'assets/immich-logo.png' adaptive_icon_background: '#ffffff' diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart new file mode 100644 index 0000000000..b2b7404b5a --- /dev/null +++ b/mobile/test/drift/main/generated/schema.dart @@ -0,0 +1,23 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; +import 'package:drift/internal/migrations.dart'; +import 'schema_v1.dart' as v1; +import 'schema_v2.dart' as v2; + +class GeneratedHelper implements SchemaInstantiationHelper { + @override + GeneratedDatabase databaseForVersion(QueryExecutor db, int version) { + switch (version) { + case 1: + return v1.DatabaseAtV1(db); + case 2: + return v2.DatabaseAtV2(db); + default: + throw MissingSchemaException(version, versions); + } + } + + static const versions = const [1, 2]; +} diff --git a/mobile/test/drift/main/generated/schema_v1.dart b/mobile/test/drift/main/generated/schema_v1.dart new file mode 100644 index 0000000000..0172201921 --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v1.dart @@ -0,0 +1,4638 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_admin" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn email = GeneratedColumn( + 'email', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn profileImagePath = GeneratedColumn( + 'profile_image_path', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0')); + @override + List get $columns => [ + id, + name, + isAdmin, + email, + profileImagePath, + updatedAt, + quotaSizeInBytes, + quotaUsageInBytes + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + isAdmin: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_admin'])!, + email: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}email'])!, + profileImagePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}profile_image_path']), + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}quota_size_in_bytes']), + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}quota_usage_in_bytes'])!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final bool isAdmin; + final String email; + final String? profileImagePath; + final DateTime updatedAt; + final int? quotaSizeInBytes; + final int quotaUsageInBytes; + const UserEntityData( + {required this.id, + required this.name, + required this.isAdmin, + required this.email, + this.profileImagePath, + required this.updatedAt, + this.quotaSizeInBytes, + required this.quotaUsageInBytes}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['is_admin'] = Variable(isAdmin); + map['email'] = Variable(email); + if (!nullToAbsent || profileImagePath != null) { + map['profile_image_path'] = Variable(profileImagePath); + } + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || quotaSizeInBytes != null) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + } + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + return map; + } + + factory UserEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + isAdmin: serializer.fromJson(json['isAdmin']), + email: serializer.fromJson(json['email']), + profileImagePath: serializer.fromJson(json['profileImagePath']), + updatedAt: serializer.fromJson(json['updatedAt']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'isAdmin': serializer.toJson(isAdmin), + 'email': serializer.toJson(email), + 'profileImagePath': serializer.toJson(profileImagePath), + 'updatedAt': serializer.toJson(updatedAt), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + }; + } + + UserEntityData copyWith( + {String? id, + String? name, + bool? isAdmin, + String? email, + Value profileImagePath = const Value.absent(), + DateTime? updatedAt, + Value quotaSizeInBytes = const Value.absent(), + int? quotaUsageInBytes}) => + UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + isAdmin: isAdmin ?? this.isAdmin, + email: email ?? this.email, + profileImagePath: profileImagePath.present + ? profileImagePath.value + : this.profileImagePath, + updatedAt: updatedAt ?? this.updatedAt, + quotaSizeInBytes: quotaSizeInBytes.present + ? quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + email: data.email.present ? data.email.value : this.email, + profileImagePath: data.profileImagePath.present + ? data.profileImagePath.value + : this.profileImagePath, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isAdmin: $isAdmin, ') + ..write('email: $email, ') + ..write('profileImagePath: $profileImagePath, ') + ..write('updatedAt: $updatedAt, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, isAdmin, email, profileImagePath, + updatedAt, quotaSizeInBytes, quotaUsageInBytes); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.isAdmin == this.isAdmin && + other.email == this.email && + other.profileImagePath == this.profileImagePath && + other.updatedAt == this.updatedAt && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value isAdmin; + final Value email; + final Value profileImagePath; + final Value updatedAt; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.isAdmin = const Value.absent(), + this.email = const Value.absent(), + this.profileImagePath = const Value.absent(), + this.updatedAt = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + this.isAdmin = const Value.absent(), + required String email, + this.profileImagePath = const Value.absent(), + this.updatedAt = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? isAdmin, + Expression? email, + Expression? profileImagePath, + Expression? updatedAt, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (isAdmin != null) 'is_admin': isAdmin, + if (email != null) 'email': email, + if (profileImagePath != null) 'profile_image_path': profileImagePath, + if (updatedAt != null) 'updated_at': updatedAt, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + }); + } + + UserEntityCompanion copyWith( + {Value? id, + Value? name, + Value? isAdmin, + Value? email, + Value? profileImagePath, + Value? updatedAt, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes}) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + isAdmin: isAdmin ?? this.isAdmin, + email: email ?? this.email, + profileImagePath: profileImagePath ?? this.profileImagePath, + updatedAt: updatedAt ?? this.updatedAt, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (profileImagePath.present) { + map['profile_image_path'] = Variable(profileImagePath.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isAdmin: $isAdmin, ') + ..write('email: $email, ') + ..write('profileImagePath: $profileImagePath, ') + ..write('updatedAt: $updatedAt, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn localDateTime = + GeneratedColumn('local_date_time', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + visibility + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}checksum'])!, + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}local_date_time']), + thumbHash: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumb_hash']), + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + visibility: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}visibility'])!, + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final int visibility; + const RemoteAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + required this.visibility}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['visibility'] = Variable(visibility); + return map; + } + + factory RemoteAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + visibility: serializer.fromJson(json['visibility']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'visibility': serializer.toJson(visibility), + }; + } + + RemoteAssetEntityData copyWith( + {String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + int? visibility}) => + RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: + localDateTime.present ? localDateTime.value : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + visibility: visibility ?? this.visibility, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + visibility: + data.visibility.present ? data.visibility.value : this.visibility, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('visibility: $visibility') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + visibility); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.visibility == this.visibility); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value visibility; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.visibility = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + required int visibility, + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? visibility, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (visibility != null) 'visibility': visibility, + }); + } + + RemoteAssetEntityCompanion copyWith( + {Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? visibility}) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + visibility: visibility ?? this.visibility, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('visibility: $visibility') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0')); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}checksum']), + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + orientation: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}orientation'])!, + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + const LocalAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + return map; + } + + factory LocalAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + }; + } + + LocalAssetEntityData copyWith( + {String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation}) => + LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + orientation: + data.orientation.present ? data.orientation.value : this.orientation, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, type, createdAt, updatedAt, width, + height, durationInSeconds, id, checksum, isFavorite, orientation); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + }); + } + + LocalAssetEntityCompanion copyWith( + {Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation}) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn key = GeneratedColumn( + 'key', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, false, + type: DriftSqlType.blob, requiredDuringInsert: true); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + key: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}key'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}value'])!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData( + {required this.userId, required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith( + {String? userId, int? key, Uint8List? value}) => + UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith( + {Value? userId, Value? key, Value? value}) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("in_timeline" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}shared_by_id'])!, + sharedWithId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}shared_with_id'])!, + inTimeline: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}in_timeline'])!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData( + {required this.sharedById, + required this.sharedWithId, + required this.inTimeline}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith( + {String? sharedById, String? sharedWithId, bool? inTimeline}) => + PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: + data.sharedById.present ? data.sharedById.value : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: + data.inTimeline.present ? data.inTimeline.value : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith( + {Value? sharedById, + Value? sharedWithId, + Value? inTimeline}) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', aliasedName, true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))')); + @override + List get $columns => + [id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + backupSelection: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}backup_selection'])!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_ios_shared_album'])!, + marker_: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}marker']), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final bool? marker_; + const LocalAlbumEntityData( + {required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.marker_}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith( + {String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value marker_ = const Value.absent()}) => + LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith( + {Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? marker_}) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const LocalAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory LocalAlbumAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + LocalAlbumAssetEntityCompanion copyWith( + {Value? assetId, Value? albumId}) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn city = GeneratedColumn( + 'city', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn state = GeneratedColumn( + 'state', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn country = GeneratedColumn( + 'country', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn('date_time_original', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn make = GeneratedColumn( + 'make', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn model = GeneratedColumn( + 'model', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + city: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}city']), + state: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}state']), + country: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}country']), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}date_time_original']), + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + exposureTime: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}exposure_time']), + fNumber: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}f_number']), + fileSize: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}file_size']), + focalLength: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}focal_length']), + latitude: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}latitude']), + longitude: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}longitude']), + iso: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}iso']), + make: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}make']), + model: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}model']), + lens: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}lens']), + orientation: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}orientation']), + timeZone: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}time_zone']), + rating: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}rating']), + projectionType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}projection_type']), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData( + {required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: + serializer.fromJson(json['dateTimeOriginal']), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith( + {String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent()}) => + RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: + exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: + projectionType.present ? projectionType.value : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: + data.description.present ? data.description.value : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: + data.focalLength.present ? data.focalLength.value : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: + data.orientation.present ? data.orientation.value : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith( + {Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType}) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\'')); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL')); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))'), + defaultValue: const CustomExpression('1')); + late final GeneratedColumn order = GeneratedColumn( + 'order', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}thumbnail_asset_id']), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_activity_enabled'])!, + order: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}order'])!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData( + {required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith( + {String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order}) => + RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: + data.description.present ? data.description.value : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, description, createdAt, updatedAt, + ownerId, thumbnailAssetId, isActivityEnabled, order); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith( + {Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order}) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith( + {Value? assetId, Value? albumId}) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn role = GeneratedColumn( + 'role', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + userId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + role: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}role'])!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData( + {required this.albumId, required this.userId, required this.role}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith( + {String? albumId, String? userId, int? role}) => + RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith( + {Value? albumId, Value? userId, Value? role}) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn data = GeneratedColumn( + 'data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_saved" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + data: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!, + isSaved: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_saved'])!, + memoryAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}memory_at'])!, + seenAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}seen_at']), + showAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}show_at']), + hideAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}hide_at']), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent()}) => + MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, deletedAt, ownerId, + type, data, isSaved, memoryAt, seenAt, showAt, hideAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt}) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + memoryId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}memory_id'])!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith( + {Value? assetId, Value? memoryId}) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id)')); + @override + List get $columns => + [id, createdAt, updatedAt, ownerId, primaryAssetId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId}) => + StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId}) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV1 extends GeneratedDatabase { + DatabaseAtV1(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final Index idxLocalAssetChecksum = Index('idx_local_asset_checksum', + 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); + late final Index uQRemoteAssetOwnerChecksum = Index( + 'UQ_remote_asset_owner_checksum', + 'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)'); + late final Index idxRemoteAssetChecksum = Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + localAssetEntity, + idxLocalAssetChecksum, + uQRemoteAssetOwnerChecksum, + idxRemoteAssetChecksum, + userMetadataEntity, + partnerEntity, + localAlbumEntity, + localAlbumAssetEntity, + remoteExifEntity, + remoteAlbumEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + stackEntity + ]; + @override + int get schemaVersion => 1; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/drift/main/generated/schema_v2.dart b/mobile/test/drift/main/generated/schema_v2.dart new file mode 100644 index 0000000000..bdba8db557 --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v2.dart @@ -0,0 +1,4672 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_admin" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn email = GeneratedColumn( + 'email', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn profileImagePath = GeneratedColumn( + 'profile_image_path', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0')); + @override + List get $columns => [ + id, + name, + isAdmin, + email, + profileImagePath, + updatedAt, + quotaSizeInBytes, + quotaUsageInBytes + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + isAdmin: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_admin'])!, + email: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}email'])!, + profileImagePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}profile_image_path']), + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}quota_size_in_bytes']), + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}quota_usage_in_bytes'])!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final bool isAdmin; + final String email; + final String? profileImagePath; + final DateTime updatedAt; + final int? quotaSizeInBytes; + final int quotaUsageInBytes; + const UserEntityData( + {required this.id, + required this.name, + required this.isAdmin, + required this.email, + this.profileImagePath, + required this.updatedAt, + this.quotaSizeInBytes, + required this.quotaUsageInBytes}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['is_admin'] = Variable(isAdmin); + map['email'] = Variable(email); + if (!nullToAbsent || profileImagePath != null) { + map['profile_image_path'] = Variable(profileImagePath); + } + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || quotaSizeInBytes != null) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + } + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + return map; + } + + factory UserEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + isAdmin: serializer.fromJson(json['isAdmin']), + email: serializer.fromJson(json['email']), + profileImagePath: serializer.fromJson(json['profileImagePath']), + updatedAt: serializer.fromJson(json['updatedAt']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'isAdmin': serializer.toJson(isAdmin), + 'email': serializer.toJson(email), + 'profileImagePath': serializer.toJson(profileImagePath), + 'updatedAt': serializer.toJson(updatedAt), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + }; + } + + UserEntityData copyWith( + {String? id, + String? name, + bool? isAdmin, + String? email, + Value profileImagePath = const Value.absent(), + DateTime? updatedAt, + Value quotaSizeInBytes = const Value.absent(), + int? quotaUsageInBytes}) => + UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + isAdmin: isAdmin ?? this.isAdmin, + email: email ?? this.email, + profileImagePath: profileImagePath.present + ? profileImagePath.value + : this.profileImagePath, + updatedAt: updatedAt ?? this.updatedAt, + quotaSizeInBytes: quotaSizeInBytes.present + ? quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + email: data.email.present ? data.email.value : this.email, + profileImagePath: data.profileImagePath.present + ? data.profileImagePath.value + : this.profileImagePath, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isAdmin: $isAdmin, ') + ..write('email: $email, ') + ..write('profileImagePath: $profileImagePath, ') + ..write('updatedAt: $updatedAt, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, isAdmin, email, profileImagePath, + updatedAt, quotaSizeInBytes, quotaUsageInBytes); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.isAdmin == this.isAdmin && + other.email == this.email && + other.profileImagePath == this.profileImagePath && + other.updatedAt == this.updatedAt && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value isAdmin; + final Value email; + final Value profileImagePath; + final Value updatedAt; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.isAdmin = const Value.absent(), + this.email = const Value.absent(), + this.profileImagePath = const Value.absent(), + this.updatedAt = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + this.isAdmin = const Value.absent(), + required String email, + this.profileImagePath = const Value.absent(), + this.updatedAt = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? isAdmin, + Expression? email, + Expression? profileImagePath, + Expression? updatedAt, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (isAdmin != null) 'is_admin': isAdmin, + if (email != null) 'email': email, + if (profileImagePath != null) 'profile_image_path': profileImagePath, + if (updatedAt != null) 'updated_at': updatedAt, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + }); + } + + UserEntityCompanion copyWith( + {Value? id, + Value? name, + Value? isAdmin, + Value? email, + Value? profileImagePath, + Value? updatedAt, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes}) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + isAdmin: isAdmin ?? this.isAdmin, + email: email ?? this.email, + profileImagePath: profileImagePath ?? this.profileImagePath, + updatedAt: updatedAt ?? this.updatedAt, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (profileImagePath.present) { + map['profile_image_path'] = Variable(profileImagePath.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isAdmin: $isAdmin, ') + ..write('email: $email, ') + ..write('profileImagePath: $profileImagePath, ') + ..write('updatedAt: $updatedAt, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn localDateTime = + GeneratedColumn('local_date_time', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}checksum'])!, + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}local_date_time']), + thumbHash: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumb_hash']), + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}live_photo_video_id']), + visibility: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}visibility'])!, + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final String? livePhotoVideoId; + final int visibility; + const RemoteAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + this.livePhotoVideoId, + required this.visibility}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } + map['visibility'] = Variable(visibility); + return map; + } + + factory RemoteAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), + visibility: serializer.fromJson(json['visibility']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), + 'visibility': serializer.toJson(visibility), + }; + } + + RemoteAssetEntityData copyWith( + {String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + Value livePhotoVideoId = const Value.absent(), + int? visibility}) => + RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: + localDateTime.present ? localDateTime.value : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: + data.visibility.present ? data.visibility.value : this.visibility, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value livePhotoVideoId; + final Value visibility; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + this.visibility = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + required int visibility, + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? livePhotoVideoId, + Expression? visibility, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, + if (visibility != null) 'visibility': visibility, + }); + } + + RemoteAssetEntityCompanion copyWith( + {Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? livePhotoVideoId, + Value? visibility}) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0')); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}checksum']), + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + orientation: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}orientation'])!, + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + const LocalAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + return map; + } + + factory LocalAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + }; + } + + LocalAssetEntityData copyWith( + {String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation}) => + LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + orientation: + data.orientation.present ? data.orientation.value : this.orientation, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, type, createdAt, updatedAt, width, + height, durationInSeconds, id, checksum, isFavorite, orientation); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + }); + } + + LocalAssetEntityCompanion copyWith( + {Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation}) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn key = GeneratedColumn( + 'key', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, false, + type: DriftSqlType.blob, requiredDuringInsert: true); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + key: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}key'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}value'])!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData( + {required this.userId, required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith( + {String? userId, int? key, Uint8List? value}) => + UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith( + {Value? userId, Value? key, Value? value}) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("in_timeline" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}shared_by_id'])!, + sharedWithId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}shared_with_id'])!, + inTimeline: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}in_timeline'])!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData( + {required this.sharedById, + required this.sharedWithId, + required this.inTimeline}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith( + {String? sharedById, String? sharedWithId, bool? inTimeline}) => + PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: + data.sharedById.present ? data.sharedById.value : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: + data.inTimeline.present ? data.inTimeline.value : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith( + {Value? sharedById, + Value? sharedWithId, + Value? inTimeline}) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', aliasedName, true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))')); + @override + List get $columns => + [id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + backupSelection: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}backup_selection'])!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_ios_shared_album'])!, + marker_: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}marker']), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final bool? marker_; + const LocalAlbumEntityData( + {required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.marker_}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith( + {String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value marker_ = const Value.absent()}) => + LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith( + {Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? marker_}) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const LocalAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory LocalAlbumAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + LocalAlbumAssetEntityCompanion copyWith( + {Value? assetId, Value? albumId}) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn city = GeneratedColumn( + 'city', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn state = GeneratedColumn( + 'state', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn country = GeneratedColumn( + 'country', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn('date_time_original', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn make = GeneratedColumn( + 'make', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn model = GeneratedColumn( + 'model', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + city: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}city']), + state: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}state']), + country: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}country']), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}date_time_original']), + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + exposureTime: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}exposure_time']), + fNumber: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}f_number']), + fileSize: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}file_size']), + focalLength: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}focal_length']), + latitude: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}latitude']), + longitude: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}longitude']), + iso: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}iso']), + make: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}make']), + model: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}model']), + lens: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}lens']), + orientation: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}orientation']), + timeZone: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}time_zone']), + rating: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}rating']), + projectionType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}projection_type']), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData( + {required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: + serializer.fromJson(json['dateTimeOriginal']), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith( + {String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent()}) => + RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: + exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: + projectionType.present ? projectionType.value : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: + data.description.present ? data.description.value : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: + data.focalLength.present ? data.focalLength.value : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: + data.orientation.present ? data.orientation.value : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith( + {Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType}) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\'')); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL')); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))'), + defaultValue: const CustomExpression('1')); + late final GeneratedColumn order = GeneratedColumn( + 'order', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}thumbnail_asset_id']), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_activity_enabled'])!, + order: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}order'])!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData( + {required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith( + {String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order}) => + RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: + data.description.present ? data.description.value : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, description, createdAt, updatedAt, + ownerId, thumbnailAssetId, isActivityEnabled, order); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith( + {Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order}) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith( + {Value? assetId, Value? albumId}) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn role = GeneratedColumn( + 'role', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + userId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + role: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}role'])!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData( + {required this.albumId, required this.userId, required this.role}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith( + {String? albumId, String? userId, int? role}) => + RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith( + {Value? albumId, Value? userId, Value? role}) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn data = GeneratedColumn( + 'data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_saved" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + data: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!, + isSaved: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_saved'])!, + memoryAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}memory_at'])!, + seenAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}seen_at']), + showAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}show_at']), + hideAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}hide_at']), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent()}) => + MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, deletedAt, ownerId, + type, data, isSaved, memoryAt, seenAt, showAt, hideAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt}) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + memoryId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}memory_id'])!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith( + {Value? assetId, Value? memoryId}) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id)')); + @override + List get $columns => + [id, createdAt, updatedAt, ownerId, primaryAssetId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId}) => + StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId}) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV2 extends GeneratedDatabase { + DatabaseAtV2(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final Index idxLocalAssetChecksum = Index('idx_local_asset_checksum', + 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); + late final Index uQRemoteAssetOwnerChecksum = Index( + 'UQ_remote_asset_owner_checksum', + 'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)'); + late final Index idxRemoteAssetChecksum = Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + localAssetEntity, + idxLocalAssetChecksum, + uQRemoteAssetOwnerChecksum, + idxRemoteAssetChecksum, + userMetadataEntity, + partnerEntity, + localAlbumEntity, + localAlbumAssetEntity, + remoteExifEntity, + remoteAlbumEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + stackEntity + ]; + @override + int get schemaVersion => 2; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} diff --git a/mobile/test/drift/main/migration_test.dart b/mobile/test/drift/main/migration_test.dart new file mode 100644 index 0000000000..74467492ae --- /dev/null +++ b/mobile/test/drift/main/migration_test.dart @@ -0,0 +1,38 @@ +// dart format width=80 +// ignore_for_file: unused_local_variable, unused_import +import 'package:drift/drift.dart'; +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +import 'generated/schema.dart'; +import 'generated/schema_v1.dart' as v1; +import 'generated/schema_v2.dart' as v2; + +void main() { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + late SchemaVerifier verifier; + + setUpAll(() { + verifier = SchemaVerifier(GeneratedHelper()); + }); + + group('simple database migrations', () { + // These simple tests verify all possible schema updates with a simple (no + // data) migration. This is a quick way to ensure that written database + // migrations properly alter the schema. + const versions = GeneratedHelper.versions; + for (final (i, fromVersion) in versions.indexed) { + group('from $fromVersion', () { + for (final toVersion in versions.skip(i + 1)) { + test('to $toVersion', () async { + final schema = await verifier.schemaAt(fromVersion); + final db = Drift(schema.newConnection()); + await verifier.migrateAndValidate(db, toVersion); + await db.close(); + }); + } + }); + } + }); +} From 055b93006668bd4660664a75e124e9128f78c72d Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:41:30 +0300 Subject: [PATCH 14/45] refactor(mobile): share action button in new timeline (#19967) * share asset button * include source * move to repository * formatting --- i18n/en.json | 1 + .../share_action_button.widget.dart | 40 ++++++++- .../asset_viewer/bottom_bar.widget.dart | 2 +- .../asset_viewer/bottom_sheet.widget.dart | 2 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../local_album_bottom_sheet.widget.dart | 2 +- .../locked_folder_bottom_sheet.widget.dart | 2 +- .../partner_detail_bottom_sheet.widget.dart | 3 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 23 ++++- .../repositories/asset_api.repository.dart | 5 ++ .../repositories/asset_media.repository.dart | 84 +++++++++++++++++-- mobile/lib/services/action.service.dart | 10 ++- 15 files changed, 158 insertions(+), 24 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 6cabcecf7d..11df6fc3d3 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1693,6 +1693,7 @@ "settings_saved": "Settings saved", "setup_pin_code": "Setup a PIN code", "share": "Share", + "share_action_prompt": "Shared {count} assets", "share_add_photos": "Add photos", "share_assets_selected": "{count} selected", "share_dialog_preparing": "Preparing...", diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart index 6a8864c14f..1b1553dabc 100644 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -1,12 +1,49 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class ShareActionButton extends ConsumerWidget { - const ShareActionButton({super.key}); + final ActionSource source; + + const ShareActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).shareAssets(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (!context.mounted) { + return; + } + + if (!result.success) { + ImmichToast.show( + context: context, + msg: 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } else if (result.count > 0) { + ImmichToast.show( + context: context, + msg: 'share_action_prompt' + .t(context: context, args: {'count': result.count.toString()}), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { @@ -14,6 +51,7 @@ class ShareActionButton extends ConsumerWidget { iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, label: 'share'.t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index a28d5eafa4..9237c3bcdb 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -38,7 +38,7 @@ class ViewerBottomBar extends ConsumerWidget { } final actions = [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.viewer), const _EditActionButton(), if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 0ad87e05e8..6695022d1e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -45,7 +45,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { ); final actions = [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.viewer), if (asset.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 3c21630d2e..7b1175e8be 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -33,7 +33,7 @@ class ArchiveBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index d73d3c22e0..0615a857a9 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -33,7 +33,7 @@ class FavoriteBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 6726f18246..61414252da 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -33,7 +33,7 @@ class GeneralBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart index d41f8ecb96..2ad0fe7485 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/local_album_bottom_sheet.widget.dart @@ -16,7 +16,7 @@ class LocalAlbumBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(), + ShareActionButton(source: ActionSource.timeline), DeleteLocalActionButton(source: ActionSource.timeline), UploadActionButton(), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index 97b2646f32..ef71f3a3a3 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -17,7 +17,7 @@ class LockedFolderBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(), + ShareActionButton(source: ActionSource.timeline), DownloadActionButton(), DeletePermanentActionButton(source: ActionSource.timeline), RemoveFromLockFolderActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 7af8ab7c86..1cdf6f28d6 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; @@ -14,7 +15,7 @@ class PartnerDetailBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - ShareActionButton(), + ShareActionButton(source: ActionSource.timeline), DownloadActionButton(), ], ); diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index 1ed662884a..c2d0d5c85a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -36,7 +36,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { maxChildSize: 0.4, shouldCloseOnMinExtent: false, actions: [ - const ShareActionButton(), + const ShareActionButton(source: ActionSource.timeline), if (multiselect.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 25b5783137..b53417d021 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -58,15 +58,18 @@ class ActionNotifier extends Notifier { .toList(growable: false); } - Iterable _getIdsForSource(ActionSource source) { - final Set assets = switch (source) { + Set _getAssets(ActionSource source) { + return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, ActionSource.viewer => switch (ref.read(currentAssetNotifier)) { BaseAsset asset => {asset}, null => const {}, }, }; + } + Iterable _getIdsForSource(ActionSource source) { + final Set assets = _getAssets(source); return switch (T) { const (RemoteAsset) => assets.whereType(), const (LocalAsset) => assets.whereType(), @@ -266,6 +269,22 @@ class ActionNotifier extends Notifier { ); } } + + Future shareAssets(ActionSource source) async { + final ids = _getAssets(source).toList(growable: false); + + try { + final count = await _service.shareAssets(ids); + return ActionResult(count: count, success: true); + } catch (error, stack) { + _logger.severe('Failed to share assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } } extension on Iterable { diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 0dff309172..cd49369a2a 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -83,6 +84,10 @@ class AssetApiRepository extends ApiRepository { ); } + Future downloadAsset(String id) { + return _api.downloadAssetWithHttpInfo(id); + } + _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { AssetVisibilityEnum.timeline => AssetVisibility.timeline, AssetVisibilityEnum.hidden => AssetVisibility.hidden, diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index bb1e6f414f..8708ce9cfd 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -1,27 +1,40 @@ +import 'dart:io'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/asset.entity.dart' as asset_entity; import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/utils/hash.dart'; -import 'package:photo_manager/photo_manager.dart' hide AssetType; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/extensions/response_extensions.dart'; +import 'package:share_plus/share_plus.dart'; -final assetMediaRepositoryProvider = - Provider((ref) => const AssetMediaRepository()); +final assetMediaRepositoryProvider = Provider( + (ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)), +); class AssetMediaRepository { - const AssetMediaRepository(); + final AssetApiRepository _assetApiRepository; + static final Logger _log = Logger("AssetMediaRepository"); + + const AssetMediaRepository(this._assetApiRepository); + Future> deleteAll(List ids) => PhotoManager.editor.deleteWithIds(ids); - Future get(String id) async { + Future get(String id) async { final entity = await AssetEntity.fromId(id); return toAsset(entity); } - static Asset? toAsset(AssetEntity? local) { + static asset_entity.Asset? toAsset(AssetEntity? local) { if (local == null) return null; - final Asset asset = Asset( + final asset_entity.Asset asset = asset_entity.Asset( checksum: "", localId: local.id, ownerId: fastHash(Store.get(StoreKey.currentUser).id), @@ -29,7 +42,7 @@ class AssetMediaRepository { fileModifiedAt: local.modifiedDateTime, updatedAt: local.modifiedDateTime, durationInSeconds: local.duration, - type: AssetType.values[local.typeInt], + type: asset_entity.AssetType.values[local.typeInt], fileName: local.title!, width: local.width, height: local.height, @@ -57,4 +70,57 @@ class AssetMediaRepository { // otherwise using the `entity.title` would return a random GUID return await entity.titleAsync; } + + // TODO: make this more efficient + Future shareAssets(List assets) async { + final downloadedXFiles = []; + + for (var asset in assets) { + final localId = (asset is LocalAsset) + ? asset.id + : asset is RemoteAsset + ? asset.localId + : null; + if (localId != null) { + File? f = + await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0) + .originFile; + downloadedXFiles.add(XFile(f!.path)); + } else if (asset is RemoteAsset) { + final tempDir = await getTemporaryDirectory(); + final name = asset.name; + final tempFile = await File('${tempDir.path}/$name').create(); + final res = await _assetApiRepository.downloadAsset(asset.id); + + if (res.statusCode != 200) { + _log.severe("Download for $name failed", res.toLoggerString()); + continue; + } + + await tempFile.writeAsBytes(res.bodyBytes); + downloadedXFiles.add(XFile(tempFile.path)); + } else { + _log.warning("Asset type not supported for sharing: $asset"); + continue; + } + } + + if (downloadedXFiles.isEmpty) { + _log.warning("No asset can be retrieved for share"); + return 0; + } + + final result = await Share.shareXFiles(downloadedXFiles); + + for (var file in downloadedXFiles) { + try { + await File(file.path).delete(); + } catch (e) { + _log.warning("Failed to delete temporary file: ${file.path}", e); + } + } + return result.status == ShareResultStatus.success + ? downloadedXFiles.length + : 0; + } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index bb2e1514e8..9559c5d316 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -12,7 +12,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; -import 'package:maplibre_gl/maplibre_gl.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' as maplibre; import 'package:riverpod_annotation/riverpod_annotation.dart'; final actionServiceProvider = Provider( @@ -124,12 +124,12 @@ class ActionService { List remoteIds, BuildContext context, ) async { - LatLng? initialLatLng; + maplibre.LatLng? initialLatLng; if (remoteIds.length == 1) { final exif = await _remoteAssetRepository.getExif(remoteIds[0]); if (exif?.latitude != null && exif?.longitude != null) { - initialLatLng = LatLng(exif!.latitude!, exif.longitude!); + initialLatLng = maplibre.LatLng(exif!.latitude!, exif.longitude!); } } @@ -165,4 +165,8 @@ class ActionService { return removedCount; } + + Future shareAssets(List assets) { + return _assetMediaRepository.shareAssets(assets); + } } From 03ff425664de4e5568474f441efc9ab4961c1f4a Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Thu, 17 Jul 2025 12:08:32 -0500 Subject: [PATCH 15/45] chore(mobile): update casting to new asset viewer (#19994) * update casting to new asset viewer * handle websocket --------- Co-authored-by: Alex --- .../lib/pages/common/gallery_viewer.page.dart | 4 +- mobile/lib/pages/common/tab_shell.page.dart | 3 +- .../base_action_button.widget.dart | 5 +- .../cast_action_button.widget.dart | 32 ++++++++++++ .../asset_viewer/asset_viewer.page.dart | 49 +++++++++++++++++++ .../asset_viewer/top_app_bar.widget.dart | 13 +++++ .../providers/app_life_cycle.provider.dart | 14 +++--- mobile/lib/providers/cast.provider.dart | 24 ++++++++- mobile/lib/services/gcast.service.dart | 17 +++---- .../custom_video_player_controls.dart | 2 +- 10 files changed, 140 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 6fdbecced1..539406365a 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -125,7 +125,7 @@ class GalleryViewerPage extends HookConsumerWidget { final asset = loadAsset(currentIndex.value); if (asset.isRemote) { - ref.read(castProvider.notifier).loadMedia(asset, false); + ref.read(castProvider.notifier).loadMediaOld(asset, false); } else { if (isCasting) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -394,7 +394,7 @@ class GalleryViewerPage extends HookConsumerWidget { // send image to casting if the server has it if (newAsset.isRemote) { - ref.read(castProvider.notifier).loadMedia(newAsset, false); + ref.read(castProvider.notifier).loadMediaOld(newAsset, false); } else { context.scaffoldMessenger.clearSnackBars(); diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index c8ca7ea061..007dc4c9d6 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/migration.dart'; @@ -24,8 +25,8 @@ class _TabShellPageState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(websocketProvider.notifier).connect(); runNewSync(ref, full: true); }); } diff --git a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart index 94e3610a57..2ad285326c 100644 --- a/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/base_action_button.widget.dart @@ -6,6 +6,7 @@ class BaseActionButton extends StatelessWidget { super.key, required this.label, required this.iconData, + this.iconColor, this.onPressed, this.onLongPressed, this.maxWidth = 90.0, @@ -15,6 +16,7 @@ class BaseActionButton extends StatelessWidget { final String label; final IconData iconData; + final Color? iconColor; final double maxWidth; final double? minWidth; final bool menuItem; @@ -27,7 +29,8 @@ class BaseActionButton extends StatelessWidget { minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0); final iconTheme = IconTheme.of(context); final iconSize = iconTheme.size ?? 24.0; - final iconColor = iconTheme.color ?? context.themeData.iconTheme.color; + final iconColor = + this.iconColor ?? iconTheme.color ?? context.themeData.iconTheme.color; final textColor = context.themeData.textTheme.labelLarge?.color; if (menuItem) { diff --git a/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart new file mode 100644 index 0000000000..2900d55834 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/cast_action_button.widget.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; +import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart'; + +class CastActionButton extends ConsumerWidget { + const CastActionButton({super.key, this.menuItem = true}); + + final bool menuItem; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isCasting = ref.watch(castProvider.select((c) => c.isCasting)); + + return BaseActionButton( + iconData: isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded, + iconColor: + isCasting ? context.primaryColor : null, // null = default color + label: "cast".t(context: context), + onPressed: () { + showDialog( + context: context, + builder: (context) => const CastDialog(), + ); + }, + menuItem: menuItem, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index dfc0023685..1c0f28413a 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -18,6 +19,7 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart' import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; @@ -184,6 +186,40 @@ class _AssetViewerState extends ConsumerState { } }); _delayedOperations.add(timer); + + _handleCasting(asset); + } + + void _handleCasting(BaseAsset asset) { + if (!ref.read(castProvider).isCasting) return; + + // hide any casting snackbars if they exist + context.scaffoldMessenger.hideCurrentSnackBar(); + + // send image to casting if the server has it + if (asset.hasRemote) { + final remoteAsset = asset as RemoteAsset; + + ref.read(castProvider.notifier).loadMedia(remoteAsset, false); + } else { + // casting cannot show local assets + context.scaffoldMessenger.clearSnackBars(); + + if (ref.read(castProvider).isCasting) { + ref.read(castProvider.notifier).stop(); + context.scaffoldMessenger.showSnackBar( + SnackBar( + duration: const Duration(seconds: 2), + content: Text( + "local_asset_cast_failed".tr(), + style: context.textTheme.bodyLarge?.copyWith( + color: context.primaryColor, + ), + ), + ), + ); + } + } } void _onPageBuild(PhotoViewControllerBase controller) { @@ -570,6 +606,19 @@ class _AssetViewerState extends ConsumerState { ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); ref.watch(isPlayingMotionVideoProvider); + // Listen for casting changes and send initial asset to the cast provider + ref.listen(castProvider.select((value) => value.isCasting), + (_, isCasting) async { + if (!isCasting) return; + + final asset = ref.read(currentAssetNotifier); + if (asset == null) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleCasting(asset); + }); + }); + // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Issue: https://github.com/flutter/flutter/issues/109037 // TODO: Add a custom scrum builder once the fix lands on stable diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index 0f3d46f673..c85c4390ae 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -5,12 +5,15 @@ import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const ViewerTopAppBar({super.key}); @@ -37,7 +40,17 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { opacity = 0; } + final isCasting = ref.watch( + castProvider.select((c) => c.isCasting), + ); + final websocketConnected = + ref.watch(websocketProvider.select((c) => c.isConnected)); + final actions = [ + if (isCasting || (asset.hasRemote && websocketConnected)) + const CastActionButton( + menuItem: true, + ), if (asset.hasRemote && isOwner && !asset.isFavorite) const FavoriteActionButton(source: ActionSource.viewer, menuItem: true), if (asset.hasRemote && isOwner && asset.isFavorite) diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index b4b7b0d406..997058d763 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -141,13 +141,15 @@ class AppLifeCycleNotifier extends StateNotifier { state = AppLifeCycleEnum.paused; _wasPaused = true; - if (!Store.isBetaTimelineEnabled && - _ref.read(authProvider).isAuthenticated) { - // Do not cancel backup if manual upload is in progress - if (_ref.read(backupProvider.notifier).backupProgress != - BackUpProgressEnum.manualInProgress) { - _ref.read(backupProvider.notifier).cancelBackup(); + if (_ref.read(authProvider).isAuthenticated) { + if (!Store.isBetaTimelineEnabled) { + // Do not cancel backup if manual upload is in progress + if (_ref.read(backupProvider.notifier).backupProgress != + BackUpProgressEnum.manualInProgress) { + _ref.read(backupProvider.notifier).cancelBackup(); + } } + _ref.read(websocketProvider.notifier).disconnect(); } diff --git a/mobile/lib/providers/cast.provider.dart b/mobile/lib/providers/cast.provider.dart index f70bdad9dc..11cdcd54c5 100644 --- a/mobile/lib/providers/cast.provider.dart +++ b/mobile/lib/providers/cast.provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart' as old_asset_entity; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/services/gcast.service.dart'; @@ -50,10 +51,29 @@ class CastNotifier extends StateNotifier { state = state.copyWith(castState: castState); } - void loadMedia(Asset asset, bool reload) { + void loadMedia(RemoteAsset asset, bool reload) { _gCastService.loadMedia(asset, reload); } + // TODO: remove this when we migrate to new timeline + void loadMediaOld(old_asset_entity.Asset asset, bool reload) { + final remoteAsset = RemoteAsset( + id: asset.remoteId.toString(), + name: asset.name, + ownerId: asset.ownerId.toString(), + checksum: asset.checksum, + type: asset.type == old_asset_entity.AssetType.image + ? AssetType.image + : asset.type == old_asset_entity.AssetType.video + ? AssetType.video + : AssetType.other, + createdAt: asset.fileCreatedAt, + updatedAt: asset.updatedAt, + ); + + _gCastService.loadMedia(remoteAsset, reload); + } + Future connect(CastDestinationType type, dynamic device) async { switch (type) { case CastDestinationType.googleCast: diff --git a/mobile/lib/services/gcast.service.dart b/mobile/lib/services/gcast.service.dart index 5a8c27b0db..6d6646fe50 100644 --- a/mobile/lib/services/gcast.service.dart +++ b/mobile/lib/services/gcast.service.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:cast/session.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/models/cast/cast_manager_state.dart'; import 'package:immich_mobile/models/sessions/session_create_response.model.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; @@ -156,12 +156,10 @@ class GCastService { return bufferedExpiration.isAfter(DateTime.now()); } - void loadMedia(Asset asset, bool reload) async { + void loadMedia(RemoteAsset asset, bool reload) async { if (!isConnected) { return; - } else if (asset.remoteId == null) { - return; - } else if (asset.remoteId == currentAssetId && !reload) { + } else if (asset.id == currentAssetId && !reload) { return; } @@ -176,10 +174,10 @@ class GCastService { final unauthenticatedUrl = asset.isVideo ? getPlaybackUrlForRemoteId( - asset.remoteId!, + asset.id, ) : getThumbnailUrlForRemoteId( - asset.remoteId!, + asset.id, type: AssetMediaSize.fullsize, ); @@ -187,8 +185,7 @@ class GCastService { "$unauthenticatedUrl&sessionKey=${sessionKey?.token}"; // get image mime type - final mimeType = - await _assetApiRepository.getAssetMIMEType(asset.remoteId!); + final mimeType = await _assetApiRepository.getAssetMIMEType(asset.id); if (mimeType == null) { return; @@ -205,7 +202,7 @@ class GCastService { "autoplay": true, }); - currentAssetId = asset.remoteId; + currentAssetId = asset.id; // we need to poll for media status since the cast device does not // send a message when the media is loaded for whatever reason diff --git a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart index d64e507170..18565c8332 100644 --- a/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart +++ b/mobile/lib/widgets/asset_viewer/custom_video_player_controls.dart @@ -76,7 +76,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget { if (asset == null) { return; } - ref.read(castProvider.notifier).loadMedia(asset, true); + ref.read(castProvider.notifier).loadMediaOld(asset, true); } return; } From 2046dcc5b4bcaca87e2d430e7ad9015d7c002991 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Thu, 17 Jul 2025 22:43:21 +0530 Subject: [PATCH 16/45] fix: mobile storage status check (#19986) * fix: _shouldUseLocalAsset check * show storage indicators in local album view * update local thumb provider to work with remote asset * update checks * do not show upload button when selection is only merged assets --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- .../repositories/timeline.repository.dart | 16 ++++- .../pages/local_timeline.page.dart | 1 + .../bottom_sheet/location_details.widget.dart | 2 +- .../widgets/images/image_provider.dart | 18 +++++- .../widgets/images/local_image_provider.dart | 61 +++++++++++-------- 5 files changed, 66 insertions(+), 32 deletions(-) diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index a125d87d8d..4ec8a93b8d 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -165,15 +165,25 @@ class DriftTimelineRepository extends DriftDatabaseRepository { _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), useColumns: false, ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum + .equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), ], ) + ..addColumns([_db.remoteAssetEntity.id]) ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) ..limit(count, offset: offset); - return query - .map((row) => row.readTable(_db.localAssetEntity).toDto()) - .get(); + return query.map((row) { + final asset = row.readTable(_db.localAssetEntity).toDto(); + return asset.copyWith( + remoteId: row.read(_db.remoteAssetEntity.id), + ); + }).get(); } TimelineQuery remoteAlbum(String albumId, GroupAssetsBy groupBy) => ( diff --git a/mobile/lib/presentation/pages/local_timeline.page.dart b/mobile/lib/presentation/pages/local_timeline.page.dart index b4df0f64e2..fd4e44616b 100644 --- a/mobile/lib/presentation/pages/local_timeline.page.dart +++ b/mobile/lib/presentation/pages/local_timeline.page.dart @@ -30,6 +30,7 @@ class LocalTimelinePage extends StatelessWidget { child: Timeline( appBar: MesmerizingSliverAppBar(title: album.name), bottomSheet: const LocalAlbumBottomSheet(), + showStorageIndicator: true, ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart index 2d22d063bd..2bf52bd094 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart @@ -72,7 +72,7 @@ class _SheetLocationDetailsState extends ConsumerState { // Guard no lat/lng if (!hasCoordinates || - (asset is LocalAsset && !(asset as LocalAsset).hasRemote)) { + (asset != null && asset is LocalAsset && asset!.hasRemote)) { return const SizedBox.shrink(); } diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart index e79665baf7..970bb581cf 100644 --- a/mobile/lib/presentation/widgets/images/image_provider.dart +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -12,7 +12,13 @@ ImageProvider getFullImageProvider( // Create new provider and cache it final ImageProvider provider; if (_shouldUseLocalAsset(asset)) { - provider = LocalFullImageProvider(asset: asset as LocalAsset, size: size); + final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; + provider = LocalFullImageProvider( + id: id, + name: asset.name, + size: size, + type: asset.type, + ); } else { final String assetId; if (asset is LocalAsset && asset.hasRemote) { @@ -43,7 +49,13 @@ ImageProvider getThumbnailImageProvider({ } if (_shouldUseLocalAsset(asset!)) { - return LocalThumbProvider(asset: asset as LocalAsset, size: size); + final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!; + return LocalThumbProvider( + id: id, + updatedAt: asset.updatedAt, + name: asset.name, + size: size, + ); } final String assetId; @@ -59,5 +71,5 @@ ImageProvider getThumbnailImageProvider({ } bool _shouldUseLocalAsset(BaseAsset asset) => - asset is LocalAsset && + asset.hasLocal && (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index f046fcad47..65311de48a 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -21,11 +21,15 @@ class LocalThumbProvider extends ImageProvider { const AssetMediaRepository(); final CacheManager? cacheManager; - final LocalAsset asset; + final String id; + final DateTime updatedAt; + final String name; final Size size; const LocalThumbProvider({ - required this.asset, + required this.id, + required this.updatedAt, + required this.name, this.size = const Size.square(kTimelineFixedTileExtent), this.cacheManager, }); @@ -46,7 +50,10 @@ class LocalThumbProvider extends ImageProvider { scale: 1.0, informationCollector: () => [ DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Asset', key.asset), + DiagnosticsProperty('Id', key.id), + DiagnosticsProperty('Updated at', key.updatedAt), + DiagnosticsProperty('Name', key.name), + DiagnosticsProperty('Size', key.size), ], ); } @@ -57,7 +64,7 @@ class LocalThumbProvider extends ImageProvider { ImageDecoderCallback decode, ) async { final cacheKey = - '${key.asset.id}-${key.asset.updatedAt}-${key.size.width}x${key.size.height}'; + '${key.id}-${key.updatedAt}-${key.size.width}x${key.size.height}'; final fileFromCache = await cache.getFileFromCache(cacheKey); if (fileFromCache != null) { @@ -69,11 +76,11 @@ class LocalThumbProvider extends ImageProvider { } final thumbnailBytes = - await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size); + await _assetMediaRepository.getThumbnail(key.id, size: key.size); if (thumbnailBytes == null) { PaintingBinding.instance.imageCache.evict(key); throw StateError( - "Loading thumb for local photo ${key.asset.name} failed", + "Loading thumb for local photo ${key.name} failed", ); } @@ -86,14 +93,13 @@ class LocalThumbProvider extends ImageProvider { bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalThumbProvider) { - return asset.id == other.asset.id && - asset.updatedAt == other.asset.updatedAt; + return id == other.id && updatedAt == other.updatedAt; } return false; } @override - int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode; + int get hashCode => id.hashCode ^ updatedAt.hashCode; } class LocalFullImageProvider extends ImageProvider { @@ -101,12 +107,16 @@ class LocalFullImageProvider extends ImageProvider { const AssetMediaRepository(); final StorageRepository _storageRepository = const StorageRepository(); - final LocalAsset asset; + final String id; + final String name; final Size size; + final AssetType type; const LocalFullImageProvider({ - required this.asset, + required this.id, + required this.name, required this.size, + required this.type, }); @override @@ -123,7 +133,7 @@ class LocalFullImageProvider extends ImageProvider { codec: _codec(key, decode), scale: 1.0, informationCollector: () sync* { - yield ErrorDescription(asset.name); + yield ErrorDescription(name); }, ); } @@ -134,24 +144,24 @@ class LocalFullImageProvider extends ImageProvider { ImageDecoderCallback decode, ) async* { try { - switch (key.asset.type) { + switch (key.type) { case AssetType.image: yield* _decodeProgressive(key, decode); break; case AssetType.video: final codec = await _getThumbnailCodec(key, decode); if (codec == null) { - throw StateError("Failed to load preview for ${key.asset.name}"); + throw StateError("Failed to load preview for ${key.name}"); } yield codec; break; case AssetType.other: case AssetType.audio: - throw StateError('Unsupported asset type ${key.asset.type}'); + throw StateError('Unsupported asset type ${key.type}'); } } catch (error, stack) { Logger('ImmichLocalImageProvider') - .severe('Error loading local image ${key.asset.name}', error, stack); + .severe('Error loading local image ${key.name}', error, stack); throw const ImageLoadingException( 'Could not load image from local storage', ); @@ -163,7 +173,7 @@ class LocalFullImageProvider extends ImageProvider { ImageDecoderCallback decode, ) async { final thumbBytes = - await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size); + await _assetMediaRepository.getThumbnail(key.id, size: key.size); if (thumbBytes == null) { return null; } @@ -175,9 +185,9 @@ class LocalFullImageProvider extends ImageProvider { LocalFullImageProvider key, ImageDecoderCallback decode, ) async* { - final file = await _storageRepository.getFileForAsset(key.asset.id); + final file = await _storageRepository.getFileForAsset(key.id); if (file == null) { - throw StateError("Opening file for asset ${key.asset.name} failed"); + throw StateError("Opening file for asset ${key.name} failed"); } final fileSize = await file.length(); @@ -195,7 +205,7 @@ class LocalFullImageProvider extends ImageProvider { (key.size.height * progressiveMultiplier).clamp(256, 1024), ); final mediumThumb = - await _assetMediaRepository.getThumbnail(key.asset.id, size: size); + await _assetMediaRepository.getThumbnail(key.id, size: size); if (mediumThumb != null) { final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); yield await decode(mediumBuffer); @@ -212,7 +222,7 @@ class LocalFullImageProvider extends ImageProvider { (key.size.height * progressiveMultiplier).clamp(512, 2048), ); final highThumb = - await _assetMediaRepository.getThumbnail(key.asset.id, size: size); + await _assetMediaRepository.getThumbnail(key.id, size: size); if (highThumb != null) { final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); yield await decode(highBuffer); @@ -228,14 +238,15 @@ class LocalFullImageProvider extends ImageProvider { bool operator ==(Object other) { if (identical(this, other)) return true; if (other is LocalFullImageProvider) { - return asset.id == other.asset.id && - asset.updatedAt == other.asset.updatedAt && - size == other.size; + return id == other.id && + size == other.size && + type == other.type && + name == other.name; } return false; } @override int get hashCode => - asset.id.hashCode ^ asset.updatedAt.hashCode ^ size.hashCode; + id.hashCode ^ size.hashCode ^ type.hashCode ^ name.hashCode; } From 8491fe459dd6770fdf98d7504498d1802f4f3cb8 Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Fri, 18 Jul 2025 04:25:25 +0800 Subject: [PATCH 17/45] feat(mobile): drift search page (#19811) * feat(mobile): drift search page * migrate to drift page --------- Co-authored-by: Alex --- mobile/analysis_options.yaml | 1 + .../domain/models/search_result.model.dart | 38 + .../lib/domain/services/search.service.dart | 92 ++ .../lib/domain/services/timeline.service.dart | 3 + .../repositories/search_api.repository.dart | 87 ++ .../repositories/timeline.repository.dart | 6 + mobile/lib/pages/common/tab_shell.page.dart | 2 +- .../pages/dev/feat_in_development.page.dart | 10 - .../pages/search/drift_search.page.dart | 925 ++++++++++++++++++ .../search/paginated_search.provider.dart | 40 + .../widgets/timeline/timeline.state.dart | 13 +- .../widgets/timeline/timeline.widget.dart | 61 +- .../infrastructure/search.provider.dart | 12 + mobile/lib/routing/router.dart | 3 +- mobile/lib/routing/router.gr.dart | 39 + mobile/lib/services/search.service.dart | 80 +- mobile/lib/utils/provider_utils.dart | 2 + 17 files changed, 1305 insertions(+), 109 deletions(-) create mode 100644 mobile/lib/domain/models/search_result.model.dart create mode 100644 mobile/lib/domain/services/search.service.dart create mode 100644 mobile/lib/infrastructure/repositories/search_api.repository.dart create mode 100644 mobile/lib/presentation/pages/search/drift_search.page.dart create mode 100644 mobile/lib/presentation/pages/search/paginated_search.provider.dart create mode 100644 mobile/lib/providers/infrastructure/search.provider.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index 2c52595a26..7a03b54a95 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -106,6 +106,7 @@ custom_lint: - lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine - test/modules/utils/openapi_patching_test.dart # filename is self-explanatory... - lib/domain/services/sync_stream.service.dart # Making sure to comply with the type from database + - lib/domain/services/search.service.dart # refactor - lib/models/map/map_marker.model.dart diff --git a/mobile/lib/domain/models/search_result.model.dart b/mobile/lib/domain/models/search_result.model.dart new file mode 100644 index 0000000000..e8c9429432 --- /dev/null +++ b/mobile/lib/domain/models/search_result.model.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +class SearchResult { + final List assets; + final int? nextPage; + + const SearchResult({ + required this.assets, + this.nextPage, + }); + + int get totalAssets => assets.length; + + SearchResult copyWith({ + List? assets, + int? nextPage, + }) { + return SearchResult( + assets: assets ?? this.assets, + nextPage: nextPage ?? this.nextPage, + ); + } + + @override + String toString() => 'SearchResult(assets: $assets, nextPage: $nextPage)'; + + @override + bool operator ==(covariant SearchResult other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.assets, assets) && other.nextPage == nextPage; + } + + @override + int get hashCode => assets.hashCode ^ nextPage.hashCode; +} diff --git a/mobile/lib/domain/services/search.service.dart b/mobile/lib/domain/services/search.service.dart new file mode 100644 index 0000000000..052a2ca9da --- /dev/null +++ b/mobile/lib/domain/services/search.service.dart @@ -0,0 +1,92 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:logging/logging.dart'; +import 'package:openapi/api.dart' as api show AssetVisibility; +import 'package:openapi/api.dart' hide AssetVisibility; + +class SearchService { + final _log = Logger("SearchService"); + final SearchApiRepository _searchApiRepository; + + SearchService(this._searchApiRepository); + + Future?> getSearchSuggestions( + SearchSuggestionType type, { + String? country, + String? state, + String? make, + String? model, + }) async { + try { + return await _searchApiRepository.getSearchSuggestions( + type, + country: country, + state: state, + make: make, + model: model, + ); + } catch (e) { + _log.warning("Failed to get search suggestions", e); + } + return []; + } + + Future search(SearchFilter filter, int page) async { + try { + final response = await _searchApiRepository.search(filter, page); + + if (response == null || response.assets.items.isEmpty) { + return null; + } + + return SearchResult( + assets: response.assets.items.map((e) => e.toDto()).toList(), + nextPage: response.assets.nextPage?.toInt(), + ); + } catch (error, stackTrace) { + _log.severe("Failed to search for assets", error, stackTrace); + } + return null; + } +} + +extension on AssetResponseDto { + RemoteAsset toDto() { + return RemoteAsset( + id: id, + name: originalFileName, + checksum: checksum, + createdAt: fileCreatedAt, + updatedAt: updatedAt, + ownerId: ownerId, + visibility: switch (visibility) { + api.AssetVisibility.timeline => AssetVisibility.timeline, + api.AssetVisibility.hidden => AssetVisibility.hidden, + api.AssetVisibility.archive => AssetVisibility.archive, + api.AssetVisibility.locked => AssetVisibility.locked, + _ => AssetVisibility.timeline, + }, + durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + height: exifInfo?.exifImageHeight?.toInt(), + width: exifInfo?.exifImageWidth?.toInt(), + isFavorite: isFavorite, + livePhotoVideoId: livePhotoVideoId, + thumbHash: thumbhash, + localId: null, + type: type.toAssetType(), + ); + } +} + +extension on AssetTypeEnum { + AssetType toAssetType() => switch (this) { + AssetTypeEnum.IMAGE => AssetType.image, + AssetTypeEnum.VIDEO => AssetType.video, + AssetTypeEnum.AUDIO => AssetType.audio, + AssetTypeEnum.OTHER => AssetType.other, + _ => throw Exception('Unknown AssetType value: $this'), + }; +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 14a854a760..0d31f06e74 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -65,6 +65,9 @@ class TimelineFactory { TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy)); + + TimelineService fromAssets(List assets) => + TimelineService(_timelineRepository.fromAssets(assets)); } class TimelineService { diff --git a/mobile/lib/infrastructure/repositories/search_api.repository.dart b/mobile/lib/infrastructure/repositories/search_api.repository.dart new file mode 100644 index 0000000000..55604b885c --- /dev/null +++ b/mobile/lib/infrastructure/repositories/search_api.repository.dart @@ -0,0 +1,87 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' + hide AssetVisibility; +import 'package:immich_mobile/infrastructure/repositories/api.repository.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:openapi/api.dart'; + +class SearchApiRepository extends ApiRepository { + final SearchApi _api; + const SearchApiRepository(this._api); + + Future search(SearchFilter filter, int page) { + AssetTypeEnum? type; + if (filter.mediaType.index == AssetType.image.index) { + type = AssetTypeEnum.IMAGE; + } else if (filter.mediaType.index == AssetType.video.index) { + type = AssetTypeEnum.VIDEO; + } + + if (filter.context != null && filter.context!.isNotEmpty) { + return _api.searchSmart( + SmartSearchDto( + query: filter.context!, + language: filter.language, + country: filter.location.country, + state: filter.location.state, + city: filter.location.city, + make: filter.camera.make, + model: filter.camera.model, + takenAfter: filter.date.takenAfter, + takenBefore: filter.date.takenBefore, + visibility: filter.display.isArchive + ? AssetVisibility.archive + : AssetVisibility.timeline, + isFavorite: filter.display.isFavorite ? true : null, + isNotInAlbum: filter.display.isNotInAlbum ? true : null, + personIds: filter.people.map((e) => e.id).toList(), + type: type, + page: page, + size: 1000, + ), + ); + } + + return _api.searchAssets( + MetadataSearchDto( + originalFileName: filter.filename != null && filter.filename!.isNotEmpty + ? filter.filename + : null, + country: filter.location.country, + description: + filter.description != null && filter.description!.isNotEmpty + ? filter.description + : null, + state: filter.location.state, + city: filter.location.city, + make: filter.camera.make, + model: filter.camera.model, + takenAfter: filter.date.takenAfter, + takenBefore: filter.date.takenBefore, + visibility: filter.display.isArchive + ? AssetVisibility.archive + : AssetVisibility.timeline, + isFavorite: filter.display.isFavorite ? true : null, + isNotInAlbum: filter.display.isNotInAlbum ? true : null, + personIds: filter.people.map((e) => e.id).toList(), + type: type, + page: page, + size: 1000, + ), + ); + } + + Future?> getSearchSuggestions( + SearchSuggestionType type, { + String? country, + String? state, + String? make, + String? model, + }) => + _api.getSearchSuggestions( + type, + country: country, + state: state, + make: make, + model: model, + ); +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 4ec8a93b8d..a6d89b5e8e 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -302,6 +302,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository { .get(); } + TimelineQuery fromAssets(List assets) => ( + bucketSource: () => Stream.value(_generateBuckets(assets.length)), + assetSource: (offset, count) => + Future.value(assets.skip(offset).take(count).toList()), + ); + TimelineQuery remote(String ownerId, GroupAssetsBy groupBy) => _remoteQueryBuilder( filter: (row) => diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 007dc4c9d6..24efff143f 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -133,7 +133,7 @@ class _TabShellPageState extends ConsumerState { return AutoTabsRouter( routes: [ const MainTimelineRoute(), - SearchRoute(), + DriftSearchRoute(), const DriftAlbumsRoute(), const DriftLibraryRoute(), ], diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index bdb426fe22..2815b2db83 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -22,16 +22,6 @@ final _features = [ icon: Icons.timeline_rounded, onTap: (ctx, _) => ctx.pushRoute(const TabShellRoute()), ), - _Feature( - name: 'Video', - icon: Icons.video_collection_outlined, - onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()), - ), - _Feature( - name: 'Recently Taken', - icon: Icons.schedule_outlined, - onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()), - ), _Feature( name: 'Selection Mode Timeline', icon: Icons.developer_mode_rounded, diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart new file mode 100644 index 0000000000..5a5eb44b3b --- /dev/null +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -0,0 +1,925 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart'; +import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart'; +import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart'; + +@RoutePage() +class DriftSearchPage extends HookConsumerWidget { + const DriftSearchPage({super.key, this.preFilter}); + + final SearchFilter? preFilter; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textSearchType = useState(TextSearchType.context); + final searchHintText = + useState('sunrise_on_the_beach'.t(context: context)); + final textSearchController = useTextEditingController(); + final filter = useState( + SearchFilter( + people: preFilter?.people ?? {}, + location: preFilter?.location ?? SearchLocationFilter(), + camera: preFilter?.camera ?? SearchCameraFilter(), + date: preFilter?.date ?? SearchDateFilter(), + display: preFilter?.display ?? + SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + mediaType: preFilter?.mediaType ?? AssetType.other, + language: + "${context.locale.languageCode}-${context.locale.countryCode}", + ), + ); + + final previousFilter = useState(null); + + final peopleCurrentFilterWidget = useState(null); + final dateRangeCurrentFilterWidget = useState(null); + final cameraCurrentFilterWidget = useState(null); + final locationCurrentFilterWidget = useState(null); + final mediaTypeCurrentFilterWidget = useState(null); + final displayOptionCurrentFilterWidget = useState(null); + + final isSearching = useState(false); + + SnackBar searchInfoSnackBar(String message) { + return SnackBar( + content: Text( + message, + style: context.textTheme.labelLarge, + ), + showCloseIcon: true, + behavior: SnackBarBehavior.fixed, + closeIconColor: context.colorScheme.onSurface, + ); + } + + search() async { + if (filter.value.isEmpty) { + return; + } + + if (preFilter == null && filter.value == previousFilter.value) { + return; + } + + isSearching.value = true; + ref.watch(paginatedSearchProvider.notifier).clear(); + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_result'.t(context: context)), + ); + } + + previousFilter.value = filter.value; + isSearching.value = false; + } + + loadMoreSearchResult() async { + isSearching.value = true; + final hasResult = await ref + .watch(paginatedSearchProvider.notifier) + .search(filter.value); + + if (!hasResult) { + context.showSnackBar( + searchInfoSnackBar('search_no_more_result'.t(context: context)), + ); + } + + isSearching.value = false; + } + + searchPreFilter() { + if (preFilter != null) { + Future.delayed( + Duration.zero, + () { + search(); + + if (preFilter!.location.city != null) { + locationCurrentFilterWidget.value = Text( + preFilter!.location.city!, + style: context.textTheme.labelLarge, + ); + } + }, + ); + } + } + + useEffect( + () { + Future.microtask( + () => ref.invalidate(paginatedSearchProvider), + ); + searchPreFilter(); + + return null; + }, + [], + ); + + showPeoplePicker() { + handleOnSelect(Set value) { + filter.value = filter.value.copyWith( + people: value, + ); + + peopleCurrentFilterWidget.value = Text( + value + .map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)) + .join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + people: {}, + ); + + peopleCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + child: FractionallySizedBox( + heightFactor: 0.8, + child: FilterBottomSheetScaffold( + title: 'search_filter_people_title'.t(context: context), + expanded: true, + onSearch: search, + onClear: handleClear, + child: PeoplePicker( + onSelect: handleOnSelect, + filter: filter.value.people, + ), + ), + ), + ); + } + + showLocationPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + location: SearchLocationFilter( + country: value['country'], + city: value['city'], + state: value['state'], + ), + ); + + final locationText = []; + if (value['country'] != null) { + locationText.add(value['country']!); + } + + if (value['state'] != null) { + locationText.add(value['state']!); + } + + if (value['city'] != null) { + locationText.add(value['city']!); + } + + locationCurrentFilterWidget.value = Text( + locationText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + location: SearchLocationFilter(), + ); + + locationCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + child: FilterBottomSheetScaffold( + title: 'search_filter_location_title'.t(context: context), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Container( + padding: EdgeInsets.only( + bottom: context.viewInsets.bottom, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LocationPicker( + onSelected: handleOnSelect, + filter: filter.value.location, + ), + ), + ), + ), + ), + ); + } + + showCameraPicker() { + handleOnSelect(Map value) { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter( + make: value['make'], + model: value['model'], + ), + ); + + cameraCurrentFilterWidget.value = Text( + '${value['make'] ?? ''} ${value['model'] ?? ''}', + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + camera: SearchCameraFilter(), + ); + + cameraCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + isScrollControlled: true, + isDismissible: true, + child: FilterBottomSheetScaffold( + title: 'search_filter_camera_title'.t(context: context), + onSearch: search, + onClear: handleClear, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CameraPicker( + onSelect: handleOnSelect, + filter: filter.value.camera, + ), + ), + ), + ); + } + + showDatePicker() async { + final firstDate = DateTime(1900); + final lastDate = DateTime.now(); + + final date = await showDateRangePicker( + context: context, + firstDate: firstDate, + lastDate: lastDate, + currentDate: DateTime.now(), + initialDateRange: DateTimeRange( + start: filter.value.date.takenAfter ?? lastDate, + end: filter.value.date.takenBefore ?? lastDate, + ), + helpText: 'search_filter_date_title'.t(context: context), + cancelText: 'cancel'.t(context: context), + confirmText: 'select'.t(context: context), + saveText: 'save'.t(context: context), + errorFormatText: 'invalid_date_format'.t(context: context), + errorInvalidText: 'invalid_date'.t(context: context), + fieldStartHintText: 'start_date'.t(context: context), + fieldEndHintText: 'end_date'.t(context: context), + initialEntryMode: DatePickerEntryMode.calendar, + keyboardType: TextInputType.text, + ); + + if (date == null) { + filter.value = filter.value.copyWith( + date: SearchDateFilter(), + ); + + dateRangeCurrentFilterWidget.value = null; + search(); + return; + } + + filter.value = filter.value.copyWith( + date: SearchDateFilter( + takenAfter: date.start, + takenBefore: date.end.add( + const Duration( + hours: 23, + minutes: 59, + seconds: 59, + ), + ), + ), + ); + + // If date range is less than 24 hours, set the end date to the end of the day + if (date.end.difference(date.start).inHours < 24) { + dateRangeCurrentFilterWidget.value = Text( + DateFormat.yMMMd().format(date.start.toLocal()), + style: context.textTheme.labelLarge, + ); + } else { + dateRangeCurrentFilterWidget.value = Text( + 'search_filter_date_interval'.t( + context: context, + args: { + "start": DateFormat.yMMMd().format(date.start.toLocal()), + "end": DateFormat.yMMMd().format(date.end.toLocal()), + }, + ), + style: context.textTheme.labelLarge, + ); + } + + search(); + } + + // MEDIA PICKER + showMediaTypePicker() { + handleOnSelected(AssetType assetType) { + filter.value = filter.value.copyWith( + mediaType: assetType, + ); + + mediaTypeCurrentFilterWidget.value = Text( + assetType == AssetType.image + ? 'image'.t(context: context) + : assetType == AssetType.video + ? 'video'.t(context: context) + : 'all'.t(context: context), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + mediaType: AssetType.other, + ); + + mediaTypeCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'search_filter_media_type_title'.t(context: context), + onSearch: search, + onClear: handleClear, + child: MediaTypePicker( + onSelect: handleOnSelected, + filter: filter.value.mediaType, + ), + ), + ); + } + + // DISPLAY OPTION + showDisplayOptionPicker() { + handleOnSelect(Map value) { + final filterText = []; + value.forEach((key, value) { + switch (key) { + case DisplayOption.notInAlbum: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isNotInAlbum: value, + ), + ); + if (value) { + filterText.add( + 'search_filter_display_option_not_in_album' + .t(context: context), + ); + } + break; + case DisplayOption.archive: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isArchive: value, + ), + ); + if (value) { + filterText.add('archive'.t(context: context)); + } + break; + case DisplayOption.favorite: + filter.value = filter.value.copyWith( + display: filter.value.display.copyWith( + isFavorite: value, + ), + ); + if (value) { + filterText.add('favorite'.t(context: context)); + } + break; + } + }); + + if (filterText.isEmpty) { + displayOptionCurrentFilterWidget.value = null; + return; + } + + displayOptionCurrentFilterWidget.value = Text( + filterText.join(', '), + style: context.textTheme.labelLarge, + ); + } + + handleClear() { + filter.value = filter.value.copyWith( + display: SearchDisplayFilters( + isNotInAlbum: false, + isArchive: false, + isFavorite: false, + ), + ); + + displayOptionCurrentFilterWidget.value = null; + search(); + } + + showFilterBottomSheet( + context: context, + child: FilterBottomSheetScaffold( + title: 'display_options'.t(context: context), + onSearch: search, + onClear: handleClear, + child: DisplayOptionPicker( + onSelect: handleOnSelect, + filter: filter.value.display, + ), + ), + ); + } + + handleTextSubmitted(String value) { + switch (textSearchType.value) { + case TextSearchType.context: + filter.value = filter.value.copyWith( + filename: '', + context: value, + description: '', + ); + + break; + case TextSearchType.filename: + filter.value = filter.value.copyWith( + filename: value, + context: '', + description: '', + ); + + break; + case TextSearchType.description: + filter.value = filter.value.copyWith( + filename: '', + context: '', + description: value, + ); + break; + } + + search(); + } + + IconData getSearchPrefixIcon() => switch (textSearchType.value) { + TextSearchType.context => Icons.image_search_rounded, + TextSearchType.filename => Icons.abc_rounded, + TextSearchType.description => Icons.text_snippet_outlined, + }; + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + automaticallyImplyLeading: true, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: MenuAnchor( + style: MenuStyle( + elevation: const WidgetStatePropertyAll(1), + shape: WidgetStateProperty.all( + const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(24), + ), + ), + ), + padding: const WidgetStatePropertyAll( + EdgeInsets.all(4), + ), + ), + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.more_vert_rounded), + tooltip: 'Show text search menu', + ); + }, + menuChildren: [ + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.image_search_rounded), + title: Text( + 'search_by_context'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.context + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.context, + ), + onPressed: () { + textSearchType.value = TextSearchType.context; + searchHintText.value = + 'sunrise_on_the_beach'.t(context: context); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.abc_rounded), + title: Text( + 'search_filter_filename'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: textSearchType.value == TextSearchType.filename + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: textSearchType.value == TextSearchType.filename, + ), + onPressed: () { + textSearchType.value = TextSearchType.filename; + searchHintText.value = + 'file_name_or_extension'.t(context: context); + }, + ), + MenuItemButton( + child: ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text( + 'search_by_description'.t(context: context), + style: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + color: + textSearchType.value == TextSearchType.description + ? context.colorScheme.primary + : null, + ), + ), + selectedColor: context.colorScheme.primary, + selected: + textSearchType.value == TextSearchType.description, + ), + onPressed: () { + textSearchType.value = TextSearchType.description; + searchHintText.value = + 'search_by_description_example'.t(context: context); + }, + ), + ], + ), + ), + ], + title: Container( + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.onSurface.withAlpha(0), + width: 0, + ), + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withValues(alpha: 0.075), + context.colorScheme.primary.withValues(alpha: 0.09), + context.colorScheme.primary.withValues(alpha: 0.075), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SearchField( + hintText: searchHintText.value, + key: const Key('search_text_field'), + controller: textSearchController, + contentPadding: preFilter != null + ? const EdgeInsets.only(left: 24) + : const EdgeInsets.all(8), + prefixIcon: preFilter != null + ? null + : Icon( + getSearchPrefixIcon(), + color: context.colorScheme.primary, + ), + onSubmitted: handleTextSubmitted, + focusNode: ref.watch(searchInputFocusProvider), + ), + ), + ), + body: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(top: 12.0), + sliver: SliverToBoxAdapter( + child: SizedBox( + height: 50, + child: ListView( + key: const Key('search_filter_chip_list'), + shrinkWrap: true, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + SearchFilterChip( + icon: Icons.people_alt_outlined, + onTap: showPeoplePicker, + label: 'people'.t(context: context), + currentFilter: peopleCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.location_on_outlined, + onTap: showLocationPicker, + label: 'search_filter_location'.t(context: context), + currentFilter: locationCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.camera_alt_outlined, + onTap: showCameraPicker, + label: 'camera'.t(context: context), + currentFilter: cameraCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.date_range_outlined, + onTap: showDatePicker, + label: 'search_filter_date'.t(context: context), + currentFilter: dateRangeCurrentFilterWidget.value, + ), + SearchFilterChip( + key: const Key('media_type_chip'), + icon: Icons.video_collection_outlined, + onTap: showMediaTypePicker, + label: 'search_filter_media_type'.t(context: context), + currentFilter: mediaTypeCurrentFilterWidget.value, + ), + SearchFilterChip( + icon: Icons.display_settings_outlined, + onTap: showDisplayOptionPicker, + label: + 'search_filter_display_options'.t(context: context), + currentFilter: displayOptionCurrentFilterWidget.value, + ), + ], + ), + ), + ), + ), + if (isSearching.value) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else + _SearchResultGrid(onScrollEnd: loadMoreSearchResult), + ], + ), + ); + } +} + +class _SearchResultGrid extends ConsumerWidget { + final VoidCallback onScrollEnd; + + const _SearchResultGrid({required this.onScrollEnd}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchResult = ref.watch(paginatedSearchProvider); + + if (searchResult.totalAssets == 0) { + return const _SearchEmptyContent(); + } + + return NotificationListener( + onNotification: (notification) { + final isBottomSheetNotification = notification.context + ?.findAncestorWidgetOfExactType() != + null; + + final metrics = notification.metrics; + final isVerticalScroll = metrics.axis == Axis.vertical; + + if (metrics.pixels >= metrics.maxScrollExtent && + isVerticalScroll && + !isBottomSheetNotification) { + onScrollEnd(); + } + + return true; + }, + child: SliverFillRemaining( + child: ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .fromAssets(searchResult.assets); + ref.onDispose(timelineService.dispose); + return timelineService; + }, + ), + ], + child: Timeline( + key: ValueKey(searchResult.totalAssets), + appBar: null, + groupBy: GroupAssetsBy.none, + ), + ), + ), + ); + } +} + +class _SearchEmptyContent extends StatelessWidget { + const _SearchEmptyContent(); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: ListView( + shrinkWrap: true, + children: [ + const SizedBox(height: 40), + Center( + child: Image.asset( + context.isDarkTheme + ? 'assets/polaroid-dark.png' + : 'assets/polaroid-light.png', + height: 125, + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + 'search_page_search_photos_videos'.t(context: context), + style: context.textTheme.labelLarge, + ), + ), + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: _QuickLinkList(), + ), + ], + ), + ); + } +} + +class _QuickLinkList extends StatelessWidget { + const _QuickLinkList(); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + border: Border.all( + color: context.colorScheme.outline.withAlpha(10), + width: 1, + ), + gradient: LinearGradient( + colors: [ + context.colorScheme.primary.withAlpha(10), + context.colorScheme.primary.withAlpha(15), + context.colorScheme.primary.withAlpha(20), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: ListView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _QuickLink( + title: 'recently_taken'.t(context: context), + icon: Icons.schedule_outlined, + isTop: true, + onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()), + ), + _QuickLink( + title: 'videos'.t(context: context), + icon: Icons.play_circle_outline_rounded, + onTap: () => context.pushRoute(const DriftVideoRoute()), + ), + _QuickLink( + title: 'favorites'.t(context: context), + icon: Icons.favorite_border_rounded, + isBottom: true, + onTap: () => context.pushRoute(const DriftFavoriteRoute()), + ), + ], + ), + ); + } +} + +class _QuickLink extends StatelessWidget { + final String title; + final IconData icon; + final VoidCallback onTap; + final bool isTop; + final bool isBottom; + + const _QuickLink({ + required this.title, + required this.icon, + required this.onTap, + this.isTop = false, + this.isBottom = false, + }); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.only( + topLeft: Radius.circular(isTop ? 20 : 0), + topRight: Radius.circular(isTop ? 20 : 0), + bottomLeft: Radius.circular(isBottom ? 20 : 0), + bottomRight: Radius.circular(isBottom ? 20 : 0), + ); + + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + leading: Icon( + icon, + size: 26, + ), + title: Text( + title, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: onTap, + ); + } +} diff --git a/mobile/lib/presentation/pages/search/paginated_search.provider.dart b/mobile/lib/presentation/pages/search/paginated_search.provider.dart new file mode 100644 index 0000000000..84635fd0b9 --- /dev/null +++ b/mobile/lib/presentation/pages/search/paginated_search.provider.dart @@ -0,0 +1,40 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/search_result.model.dart'; +import 'package:immich_mobile/domain/services/search.service.dart'; +import 'package:immich_mobile/models/search/search_filter.model.dart'; +import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; + +final paginatedSearchProvider = + StateNotifierProvider( + (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), +); + +class PaginatedSearchNotifier extends StateNotifier { + final SearchService _searchService; + + PaginatedSearchNotifier(this._searchService) + : super(const SearchResult(assets: [], nextPage: 1)); + + Future search(SearchFilter filter) async { + if (state.nextPage == null) { + return false; + } + + final result = await _searchService.search(filter, state.nextPage!); + + if (result == null) { + return false; + } + + state = SearchResult( + assets: [...state.assets, ...result.assets], + nextPage: result.nextPage, + ); + + return true; + } + + clear() { + state = const SearchResult(assets: [], nextPage: 1); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 30a4088ce2..91003fb1a1 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -15,6 +15,7 @@ class TimelineArgs { final double spacing; final int columnCount; final bool showStorageIndicator; + final GroupAssetsBy? groupBy; const TimelineArgs({ required this.maxWidth, @@ -22,6 +23,7 @@ class TimelineArgs { this.spacing = kTimelineSpacing, this.columnCount = kTimelineColumnCount, this.showStorageIndicator = false, + this.groupBy, }); @override @@ -30,7 +32,8 @@ class TimelineArgs { maxWidth == other.maxWidth && maxHeight == other.maxHeight && columnCount == other.columnCount && - showStorageIndicator == other.showStorageIndicator; + showStorageIndicator == other.showStorageIndicator && + groupBy == other.groupBy; } @override @@ -39,7 +42,8 @@ class TimelineArgs { maxHeight.hashCode ^ spacing.hashCode ^ columnCount.hashCode ^ - showStorageIndicator.hashCode; + showStorageIndicator.hashCode ^ + groupBy.hashCode; } class TimelineState { @@ -97,8 +101,9 @@ final timelineSegmentProvider = StreamProvider.autoDispose>( final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); final tileExtent = math.max(0, availableTileWidth) / columnCount; - final groupBy = GroupAssetsBy - .values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; + final groupBy = args.groupBy ?? + GroupAssetsBy + .values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; final timelineService = ref.watch(timelineServiceProvider); yield* timelineService.watchBuckets().map((buckets) { diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index 490f2bcff2..d279937417 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -27,8 +28,13 @@ class Timeline extends StatelessWidget { this.topSliverWidget, this.topSliverWidgetHeight, this.showStorageIndicator = false, - this.appBar, + this.appBar = const ImmichSliverAppBar( + floating: true, + pinned: false, + snap: false, + ), this.bottomSheet = const GeneralBottomSheet(), + this.groupBy, }); final Widget? topSliverWidget; @@ -36,6 +42,8 @@ class Timeline extends StatelessWidget { final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; + final GroupAssetsBy? groupBy; + @override Widget build(BuildContext context) { return Scaffold( @@ -50,6 +58,7 @@ class Timeline extends StatelessWidget { settingsProvider.select((s) => s.get(Setting.tilesPerRow)), ), showStorageIndicator: showStorageIndicator, + groupBy: groupBy, ), ), ], @@ -112,13 +121,17 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { return asyncSegments.widgetWhen( onData: (segments) { final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; - final statusBarHeight = context.padding.top; final double appBarExpandedHeight = widget.appBar != null && widget.appBar is MesmerizingSliverAppBar ? 200 : 0; - final totalAppBarHeight = statusBarHeight + kToolbarHeight; + final topPadding = context.padding.top + + (widget.appBar == null ? 0 : kToolbarHeight) + + 10; + const scrubberBottomPadding = 100.0; + final bottomPadding = context.padding.bottom + + (widget.appBar == null ? 0 : scrubberBottomPadding); return PrimaryScrollController( controller: _scrollController, @@ -127,8 +140,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { Scrubber( layoutSegments: segments, timelineHeight: maxHeight, - topPadding: totalAppBarHeight + 10, - bottomPadding: context.padding.bottom + scrubberBottomPadding, + topPadding: topPadding, + bottomPadding: bottomPadding, monthSegmentSnappingOffset: widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight, child: CustomScrollView( @@ -137,13 +150,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { slivers: [ if (isSelectionMode) const SelectionSliverAppBar() - else - widget.appBar ?? - const ImmichSliverAppBar( - floating: true, - pinned: false, - snap: false, - ), + else if (widget.appBar != null) + widget.appBar!, if (widget.topSliverWidget != null) widget.topSliverWidget!, _SliverSegmentedList( segments: segments, @@ -188,21 +196,22 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> { child: _MultiSelectStatusButton(), ), ), - Consumer( - builder: (_, consumerRef, child) { - final isMultiSelectEnabled = consumerRef.watch( - multiSelectProvider.select( - (s) => s.isEnabled, - ), - ); + if (widget.bottomSheet != null) + Consumer( + builder: (_, consumerRef, child) { + final isMultiSelectEnabled = consumerRef.watch( + multiSelectProvider.select( + (s) => s.isEnabled, + ), + ); - if (isMultiSelectEnabled) { - return child!; - } - return const SizedBox.shrink(); - }, - child: widget.bottomSheet, - ), + if (isMultiSelectEnabled) { + return child!; + } + return const SizedBox.shrink(); + }, + child: widget.bottomSheet, + ), ], ], ), diff --git a/mobile/lib/providers/infrastructure/search.provider.dart b/mobile/lib/providers/infrastructure/search.provider.dart new file mode 100644 index 0000000000..cdcd3ee43b --- /dev/null +++ b/mobile/lib/providers/infrastructure/search.provider.dart @@ -0,0 +1,12 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/search.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; + +final searchApiRepositoryProvider = Provider( + (ref) => SearchApiRepository(ref.watch(apiServiceProvider).searchApi), +); + +final searchServiceProvider = Provider( + (ref) => SearchService(ref.watch(searchApiRepositoryProvider)), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a6666ec6eb..94e3437d66 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -91,6 +91,7 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -189,7 +190,7 @@ class AppRouter extends RootStackRouter { guards: [_authGuard, _duplicateGuard], ), AutoRoute( - page: SearchRoute.page, + page: DriftSearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false, ), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 7412e1dee5..716d5ef89a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -991,6 +991,45 @@ class DriftRecentlyTakenRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftSearchPage] +class DriftSearchRoute extends PageRouteInfo { + DriftSearchRoute({ + Key? key, + SearchFilter? preFilter, + List? children, + }) : super( + DriftSearchRoute.name, + args: DriftSearchRouteArgs(key: key, preFilter: preFilter), + initialChildren: children, + ); + + static const String name = 'DriftSearchRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const DriftSearchRouteArgs(), + ); + return DriftSearchPage(key: args.key, preFilter: args.preFilter); + }, + ); +} + +class DriftSearchRouteArgs { + const DriftSearchRouteArgs({this.key, this.preFilter}); + + final Key? key; + + final SearchFilter? preFilter; + + @override + String toString() { + return 'DriftSearchRouteArgs{key: $key, preFilter: $preFilter}'; + } +} + /// generated route for /// [DriftTrashPage] class DriftTrashRoute extends PageRouteInfo { diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index 5d3b08aaed..aa72a7908b 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; +import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/search/search_result.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:logging/logging.dart'; @@ -14,15 +15,21 @@ final searchServiceProvider = Provider( (ref) => SearchService( ref.watch(apiServiceProvider), ref.watch(assetRepositoryProvider), + ref.watch(searchApiRepositoryProvider), ), ); class SearchService { final ApiService _apiService; final AssetRepository _assetRepository; + final SearchApiRepository _searchApiRepository; final _log = Logger("SearchService"); - SearchService(this._apiService, this._assetRepository); + SearchService( + this._apiService, + this._assetRepository, + this._searchApiRepository, + ); Future?> getSearchSuggestions( SearchSuggestionType type, { @@ -32,7 +39,7 @@ class SearchService { String? model, }) async { try { - return await _apiService.searchApi.getSearchSuggestions( + return await _searchApiRepository.getSearchSuggestions( type, country: country, state: state, @@ -47,76 +54,15 @@ class SearchService { Future search(SearchFilter filter, int page) async { try { - SearchResponseDto? response; - AssetTypeEnum? type; - if (filter.mediaType == AssetType.image) { - type = AssetTypeEnum.IMAGE; - } else if (filter.mediaType == AssetType.video) { - type = AssetTypeEnum.VIDEO; - } - - if (filter.context != null && filter.context!.isNotEmpty) { - response = await _apiService.searchApi.searchSmart( - SmartSearchDto( - query: filter.context!, - language: filter.language, - country: filter.location.country, - state: filter.location.state, - city: filter.location.city, - make: filter.camera.make, - model: filter.camera.model, - takenAfter: filter.date.takenAfter, - takenBefore: filter.date.takenBefore, - visibility: filter.display.isArchive - ? AssetVisibility.archive - : AssetVisibility.timeline, - isFavorite: filter.display.isFavorite ? true : null, - isNotInAlbum: filter.display.isNotInAlbum ? true : null, - personIds: filter.people.map((e) => e.id).toList(), - type: type, - page: page, - size: 1000, - ), - ); - } else { - response = await _apiService.searchApi.searchAssets( - MetadataSearchDto( - originalFileName: - filter.filename != null && filter.filename!.isNotEmpty - ? filter.filename - : null, - country: filter.location.country, - description: - filter.description != null && filter.description!.isNotEmpty - ? filter.description - : null, - state: filter.location.state, - city: filter.location.city, - make: filter.camera.make, - model: filter.camera.model, - takenAfter: filter.date.takenAfter, - takenBefore: filter.date.takenBefore, - visibility: filter.display.isArchive - ? AssetVisibility.archive - : AssetVisibility.timeline, - isFavorite: filter.display.isFavorite ? true : null, - isNotInAlbum: filter.display.isNotInAlbum ? true : null, - personIds: filter.people.map((e) => e.id).toList(), - type: type, - page: page, - size: 1000, - ), - ); - } + final response = await _searchApiRepository.search(filter, page); if (response == null || response.assets.items.isEmpty) { return null; } return SearchResult( - assets: await _assetRepository.getAllByRemoteId( - response.assets.items.map((e) => e.id), - ), + assets: await _assetRepository + .getAllByRemoteId(response.assets.items.map((e) => e.id)), nextPage: response.assets.nextPage?.toInt(), ); } catch (error, stackTrace) { diff --git a/mobile/lib/utils/provider_utils.dart b/mobile/lib/utils/provider_utils.dart index bf18d24213..7af0e61d3c 100644 --- a/mobile/lib/utils/provider_utils.dart +++ b/mobile/lib/utils/provider_utils.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; import 'package:immich_mobile/repositories/activity_api.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; @@ -15,4 +16,5 @@ void invalidateAllApiRepositoryProviders(WidgetRef ref) { ref.invalidate(personApiRepositoryProvider); ref.invalidate(assetApiRepositoryProvider); ref.invalidate(timelineRepositoryProvider); + ref.invalidate(searchApiRepositoryProvider); } From 546f841b2cc1b91226975a9bbe67d8c43dfffb79 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 17 Jul 2025 15:58:55 -0500 Subject: [PATCH 18/45] chore: migration continue button (#20000) * feat: add toggle to switch between Isar and Sqlite * reset sqlite on beta * start sync on app open in new timeline * fix lint * migrate hashes when new timeline is selected * migrate hashes immediately after beta is enabled * show loading indicator in change timeline page * some stylings * fix some styling issue * release resources on isolate close * replace route and styling * handle migration back to old timeline * check if a provider is mounted before calling dispose on it * styling * styling and button --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- i18n/en.json | 2 +- .../pages/common/change_experience.page.dart | 24 ++++++++++------- mobile/lib/pages/common/settings.page.dart | 2 +- mobile/lib/providers/asset.provider.dart | 4 ++- .../settings/beta_timeline_list_tile.dart | 26 ++++++++++++++----- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 11df6fc3d3..89780f62b1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -373,7 +373,7 @@ "admin_password": "Admin Password", "administration": "Administration", "advanced": "Advanced", - "advanced_settings_beta_timeline_subtitle": "Try the new app experience.", + "advanced_settings_beta_timeline_subtitle": "Try the new app experience", "advanced_settings_beta_timeline_title": "Beta Timeline", "advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.", "advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter", diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index c54f627b61..74c3116962 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -57,14 +57,6 @@ class _ChangeExperiencePageState extends ConsumerState { await ref.read(backgroundSyncProvider).cancel(); } - Future.delayed(const Duration(seconds: 3), () { - context.replaceRoute( - widget.switchingToBeta - ? const TabShellRoute() - : const TabControllerRoute(), - ); - }); - if (mounted) { setState(() { HapticFeedback.heavyImpact(); @@ -104,7 +96,7 @@ class _ChangeExperiencePageState extends ConsumerState { duration: Durations.long4, child: hasMigrated ? Text( - "Migration success. Navigating to the new timeline...", + "Migration success!", style: context.textTheme.titleMedium, textAlign: TextAlign.center, ) @@ -115,6 +107,20 @@ class _ChangeExperiencePageState extends ConsumerState { ), ), ), + if (hasMigrated) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: ElevatedButton( + onPressed: () { + context.replaceRoute( + widget.switchingToBeta + ? const TabShellRoute() + : const TabControllerRoute(), + ); + }, + child: const Text("Continue"), + ), + ), ], ), ), diff --git a/mobile/lib/pages/common/settings.page.dart b/mobile/lib/pages/common/settings.page.dart index 42d555a2ee..e45001270c 100644 --- a/mobile/lib/pages/common/settings.page.dart +++ b/mobile/lib/pages/common/settings.page.dart @@ -143,7 +143,7 @@ class _MobileLayout extends StatelessWidget { .toList(); return ListView( physics: const ClampingScrollPhysics(), - padding: const EdgeInsets.symmetric(vertical: 10.0), + padding: const EdgeInsets.only(top: 10.0, bottom: 56), children: [ const BetaTimelineListTile(), ...settings, diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index 5b77da90f3..7fbacc3afb 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -81,7 +81,9 @@ class AssetNotifier extends StateNotifier { await _albumService.refreshDeviceAlbums(); } finally { _getAllAssetInProgress = false; - state = false; + if (mounted) { + state = false; + } } } diff --git a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart index 967c7f3e13..1de3a6e7ab 100644 --- a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart +++ b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart @@ -100,13 +100,27 @@ class _BetaTimelineListTileState extends ConsumerState [ChangeExperienceRoute(switchingToBeta: value)], ); }, - child: const Text("Yes"), + child: Text( + "YES", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: context.primaryColor, + ), + ), ), TextButton( onPressed: () { - Navigator.of(context).pop(); + context.pop(); }, - child: const Text("No"), + child: Text( + "NO", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: context.colorScheme.outline, + ), + ), ), ], ); @@ -116,18 +130,18 @@ class _BetaTimelineListTileState extends ConsumerState final gradientColors = [ Color.lerp( + context.primaryColor.withValues(alpha: 0.5), context.primaryColor.withValues(alpha: 0.3), - context.primaryColor.withValues(alpha: 0.1), _gradientAnimation.value, )!, Color.lerp( - context.primaryColor.withValues(alpha: 0.2), context.primaryColor.withValues(alpha: 0.4), + context.primaryColor.withValues(alpha: 0.6), _gradientAnimation.value, )!, Color.lerp( - context.primaryColor.withValues(alpha: 0.1), context.primaryColor.withValues(alpha: 0.3), + context.primaryColor.withValues(alpha: 0.5), _gradientAnimation.value, )!, ]; From f32cd74232a62b63fbbade4231c892b78e8bca46 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 18 Jul 2025 10:01:04 +0530 Subject: [PATCH 19/45] feat: show stacks in asset viewer (#19935) * feat: show stacks in asset viewer * fix: global key issue and flash on stack asset change * feat(mobile): stack and unstack action (#19941) * feat(mobile): stack and unstack action * add custom model * use stackId from ActionSource * Update mobile/lib/providers/infrastructure/action.provider.dart Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * fix: lint * fix: bad merge * fix: test --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Co-authored-by: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Co-authored-by: wuzihao051119 --- i18n/en.json | 2 + .../drift_schemas/main/drift_schema_v1.json | 2 +- .../drift_schemas/main/drift_schema_v2.json | 2 +- .../lib/immich_mobile_immich_lint.dart | 2 +- .../models/asset/local_asset.model.dart | 1 + .../models/asset/remote_asset.model.dart | 21 +- mobile/lib/domain/models/stack.model.dart | 24 + mobile/lib/domain/models/timeline.model.dart | 6 + mobile/lib/domain/services/asset.service.dart | 11 + mobile/lib/domain/utils/event_stream.dart | 10 +- .../entities/merged_asset.drift | 39 +- .../entities/merged_asset.drift.dart | 17 +- .../entities/remote_asset.entity.dart | 3 + .../entities/remote_asset.entity.drift.dart | 68 +- .../repositories/db.repository.drift.dart | 138 ++-- .../repositories/db.repository.steps.dart | 280 +++++---- .../repositories/remote_asset.repository.dart | 100 ++- .../repositories/sync_stream.repository.dart | 1 + .../repositories/timeline.repository.dart | 2 + .../pages/dev/main_timeline.page.dart | 5 +- .../stack_action_button.widget.dart | 42 +- .../unstack_action_button.widget.dart | 49 ++ .../asset_viewer/asset_stack.provider.dart | 24 + .../asset_viewer/asset_stack.widget.dart | 119 ++++ .../asset_viewer/asset_viewer.page.dart | 37 +- .../asset_viewer/asset_viewer.state.dart | 30 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../widgets/images/thumbnail_tile.widget.dart | 54 +- .../widgets/timeline/timeline.state.dart | 4 + .../widgets/timeline/timeline.widget.dart | 3 + .../infrastructure/action.provider.dart | 72 ++- .../repositories/asset_api.repository.dart | 26 +- mobile/lib/services/action.service.dart | 10 + .../common/mesmerizing_sliver_app_bar.dart | 1 + .../common/remote_album_sliver_app_bar.dart | 1 + mobile/lib/widgets/map/map_thumbnail.dart | 10 +- .../test/drift/main/generated/schema_v1.dart | 590 ++++++++++-------- .../test/drift/main/generated/schema_v2.dart | 556 +++++++++-------- 41 files changed, 1568 insertions(+), 802 deletions(-) create mode 100644 mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 89780f62b1..dfe2954c9f 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1795,6 +1795,7 @@ "sort_title": "Title", "source": "Source", "stack": "Stack", + "stack_action_prompt": "{count} stacked", "stack_duplicates": "Stack duplicates", "stack_select_one_photo": "Select one main photo for the stack", "stack_selected_photos": "Stack selected photos", @@ -1905,6 +1906,7 @@ "unselect_all_duplicates": "Unselect all duplicates", "unselect_all_in": "Unselect all in {group}", "unstack": "Un-stack", + "unstack_action_prompt": "{count} unstacked", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "untagged": "Untagged", "up_next": "Up next", diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 03656ce0b4..c19bcfb945 100644 --- a/mobile/drift_schemas/main/drift_schema_v1.json +++ b/mobile/drift_schemas/main/drift_schema_v1.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":4,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":6,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":7,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":8,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":9,"references":[2,8],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":10,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":11,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":12,"references":[1,11],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":13,"references":[11,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":14,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":15,"references":[1,14],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":16,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}}]} \ No newline at end of file diff --git a/mobile/drift_schemas/main/drift_schema_v2.json b/mobile/drift_schemas/main/drift_schema_v2.json index 4c1e3d68a1..c19bcfb945 100644 --- a/mobile/drift_schemas/main/drift_schema_v2.json +++ b/mobile/drift_schemas/main/drift_schema_v2.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":4,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":6,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":7,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":8,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":9,"references":[2,8],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":10,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":11,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":12,"references":[1,11],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":13,"references":[11,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":14,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":15,"references":[1,14],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":16,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}}]} \ No newline at end of file diff --git a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart index e2622fadd1..7d3ed4757e 100644 --- a/mobile/immich_lint/lib/immich_mobile_immich_lint.dart +++ b/mobile/immich_lint/lib/immich_mobile_immich_lint.dart @@ -23,7 +23,7 @@ class ImmichLinter extends PluginBase { return rules; } - static makeCode(String name, LintOptions options) => LintCode( + static LintCode makeCode(String name, LintOptions options) => LintCode( name: name, problemMessage: options.json["message"] as String, errorSeverity: ErrorSeverity.WARNING, diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 0c3e8fa942..3466a0f25b 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -45,6 +45,7 @@ class LocalAsset extends BaseAsset { }'''; } + // Not checking for remoteId here @override bool operator ==(Object other) { if (other is! LocalAsset) return false; diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 9e4cfa1f19..760a16170b 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -14,6 +14,8 @@ class RemoteAsset extends BaseAsset { final String? thumbHash; final AssetVisibility visibility; final String ownerId; + final String? stackId; + final int stackCount; const RemoteAsset({ required this.id, @@ -31,6 +33,8 @@ class RemoteAsset extends BaseAsset { this.thumbHash, this.visibility = AssetVisibility.timeline, super.livePhotoVideoId, + this.stackId, + this.stackCount = 0, }); @override @@ -56,9 +60,14 @@ class RemoteAsset extends BaseAsset { isFavorite: $isFavorite, thumbHash: ${thumbHash ?? ""}, visibility: $visibility, + stackId: ${stackId ?? ""}, + stackCount: $stackCount, + checksum: $checksum, + livePhotoVideoId: ${livePhotoVideoId ?? ""}, }'''; } + // Not checking for localId here @override bool operator ==(Object other) { if (other is! RemoteAsset) return false; @@ -67,7 +76,9 @@ class RemoteAsset extends BaseAsset { id == other.id && ownerId == other.ownerId && thumbHash == other.thumbHash && - visibility == other.visibility; + visibility == other.visibility && + stackId == other.stackId && + stackCount == other.stackCount; } @override @@ -77,7 +88,9 @@ class RemoteAsset extends BaseAsset { ownerId.hashCode ^ localId.hashCode ^ thumbHash.hashCode ^ - visibility.hashCode; + visibility.hashCode ^ + stackId.hashCode ^ + stackCount.hashCode; RemoteAsset copyWith({ String? id, @@ -95,6 +108,8 @@ class RemoteAsset extends BaseAsset { String? thumbHash, AssetVisibility? visibility, String? livePhotoVideoId, + String? stackId, + int? stackCount, }) { return RemoteAsset( id: id ?? this.id, @@ -112,6 +127,8 @@ class RemoteAsset extends BaseAsset { thumbHash: thumbHash ?? this.thumbHash, visibility: visibility ?? this.visibility, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + stackId: stackId ?? this.stackId, + stackCount: stackCount ?? this.stackCount, ); } } diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index 5404eb8f42..d7faf07a22 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -82,3 +82,27 @@ class Stack { primaryAssetId.hashCode; } } + +class StackResponse { + final String id; + final String primaryAssetId; + final List assetIds; + + const StackResponse({ + required this.id, + required this.primaryAssetId, + required this.assetIds, + }); + + @override + bool operator ==(covariant StackResponse other) { + if (identical(this, other)) return true; + + return other.id == id && + other.primaryAssetId == primaryAssetId && + other.assetIds == assetIds; + } + + @override + int get hashCode => id.hashCode ^ primaryAssetId.hashCode ^ assetIds.hashCode; +} diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart index 4a49708b74..f3b688b8b8 100644 --- a/mobile/lib/domain/models/timeline.model.dart +++ b/mobile/lib/domain/models/timeline.model.dart @@ -1,3 +1,5 @@ +import 'package:immich_mobile/domain/utils/event_stream.dart'; + enum GroupAssetsBy { day, month, @@ -38,3 +40,7 @@ class TimeBucket extends Bucket { @override int get hashCode => super.hashCode ^ date.hashCode; } + +class TimelineReloadEvent extends Event { + const TimelineReloadEvent(); +} diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart index 2c9b493187..63b1aad8c1 100644 --- a/mobile/lib/domain/services/asset.service.dart +++ b/mobile/lib/domain/services/asset.service.dart @@ -24,6 +24,17 @@ class AssetService { : _remoteAssetRepository.watchAsset(id); } + Future> getStack(RemoteAsset asset) async { + if (asset.stackId == null) { + return []; + } + + return _remoteAssetRepository.getStackChildren(asset).then((assets) { + // Include the primary asset in the stack as the first item + return [asset, ...assets]; + }); + } + Future getExif(BaseAsset asset) async { if (!asset.hasRemote) { return null; diff --git a/mobile/lib/domain/utils/event_stream.dart b/mobile/lib/domain/utils/event_stream.dart index 65ee17e12b..e728ece58b 100644 --- a/mobile/lib/domain/utils/event_stream.dart +++ b/mobile/lib/domain/utils/event_stream.dart @@ -1,17 +1,9 @@ import 'dart:async'; -sealed class Event { +class Event { const Event(); } -class TimelineReloadEvent extends Event { - const TimelineReloadEvent(); -} - -class ViewerOpenBottomSheetEvent extends Event { - const ViewerOpenBottomSheetEvent(); -} - class EventStream { EventStream._(); diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index e07edbc0c8..3dc7221c15 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -1,5 +1,6 @@ import 'remote_asset.entity.dart'; import 'local_asset.entity.dart'; +import 'stack.entity.dart'; mergedAsset: SELECT * FROM ( @@ -18,13 +19,33 @@ mergedAsset: SELECT * FROM rae.checksum, rae.owner_id, rae.live_photo_video_id, - 0 as orientation + 0 as orientation, + rae.stack_id, + COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity rae LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum + LEFT JOIN + stack_entity se ON rae.stack_id = se.id + LEFT JOIN + (SELECT + stack_id, + COUNT(*) AS total_count + FROM remote_asset_entity + WHERE deleted_at IS NULL + AND visibility = 0 + AND stack_id IS NOT NULL + GROUP BY stack_id + ) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE - rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL + AND rae.visibility = 0 + AND rae.owner_id in ? + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) UNION ALL SELECT NULL as remote_id, @@ -41,7 +62,9 @@ mergedAsset: SELECT * FROM lae.checksum, NULL as owner_id, NULL as live_photo_video_id, - lae.orientation + lae.orientation, + NULL as stack_id, + 0 AS stack_count FROM local_asset_entity lae LEFT JOIN @@ -68,8 +91,16 @@ FROM remote_asset_entity rae LEFT JOIN local_asset_entity lae ON rae.checksum = lae.checksum + LEFT JOIN + stack_entity se ON rae.stack_id = se.id WHERE - rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id in ? + rae.deleted_at IS NULL + AND rae.visibility = 0 + AND rae.owner_id in ? + AND ( + rae.stack_id IS NULL + OR rae.id = se.primary_asset_id + ) UNION ALL SELECT lae.name, diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 4ee0643706..ac3db868e1 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift. as i3; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' as i4; +import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart' + as i5; class MergedAssetDrift extends i1.ModularAccessor { MergedAssetDrift(i0.GeneratedDatabase db) : super(db); @@ -18,7 +20,7 @@ class MergedAssetDrift extends i1.ModularAccessor { final generatedlimit = $write(limit, startIndex: $arrayStartIndex); $arrayStartIndex += generatedlimit.amountOfVariables; return customSelect( - 'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}', + 'SELECT * FROM (SELECT rae.id AS remote_id, lae.id AS local_id, rae.name, rae.type, rae.created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, COALESCE(stack_count.total_count, 0) AS stack_count FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id LEFT JOIN (SELECT stack_id, COUNT(*) AS total_count FROM remote_asset_entity WHERE deleted_at IS NULL AND visibility = 0 AND stack_id IS NOT NULL GROUP BY stack_id) AS stack_count ON rae.stack_id = stack_count.stack_id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, 0 AS stack_count FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}', variables: [ for (var $ in var1) i0.Variable($), ...generatedlimit.introducedVariables @@ -26,6 +28,7 @@ class MergedAssetDrift extends i1.ModularAccessor { readsFrom: { remoteAssetEntity, localAssetEntity, + stackEntity, ...generatedlimit.watchedTables, }).map((i0.QueryRow row) => MergedAssetResult( remoteId: row.readNullable('remote_id'), @@ -44,6 +47,8 @@ class MergedAssetDrift extends i1.ModularAccessor { ownerId: row.readNullable('owner_id'), livePhotoVideoId: row.readNullable('live_photo_video_id'), orientation: row.read('orientation'), + stackId: row.readNullable('stack_id'), + stackCount: row.read('stack_count'), )); } @@ -53,7 +58,7 @@ class MergedAssetDrift extends i1.ModularAccessor { final expandedvar2 = $expandVar($arrayStartIndex, var2.length); $arrayStartIndex += var2.length; return customSelect( - 'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC', + 'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC', variables: [ i0.Variable(groupBy), for (var $ in var2) i0.Variable($) @@ -61,6 +66,7 @@ class MergedAssetDrift extends i1.ModularAccessor { readsFrom: { remoteAssetEntity, localAssetEntity, + stackEntity, }).map((i0.QueryRow row) => MergedBucketResult( assetCount: row.read('asset_count'), bucketDate: row.read('bucket_date'), @@ -73,6 +79,9 @@ class MergedAssetDrift extends i1.ModularAccessor { i4.$LocalAssetEntityTable get localAssetEntity => i1.ReadDatabaseContainer(attachedDatabase) .resultSet('local_asset_entity'); + i5.$StackEntityTable get stackEntity => + i1.ReadDatabaseContainer(attachedDatabase) + .resultSet('stack_entity'); } class MergedAssetResult { @@ -91,6 +100,8 @@ class MergedAssetResult { final String? ownerId; final String? livePhotoVideoId; final int orientation; + final String? stackId; + final int stackCount; MergedAssetResult({ this.remoteId, this.localId, @@ -107,6 +118,8 @@ class MergedAssetResult { this.ownerId, this.livePhotoVideoId, required this.orientation, + this.stackId, + required this.stackCount, }); } diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 96193e0415..0b2896538e 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -34,6 +34,8 @@ class RemoteAssetEntity extends Table IntColumn get visibility => intEnum()(); + TextColumn get stackId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -55,5 +57,6 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { visibility: visibility, livePhotoVideoId: livePhotoVideoId, localId: null, + stackId: stackId, ); } diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index 2bb7cffe59..543ed65985 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart @@ -29,6 +29,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder i0.Value deletedAt, i0.Value livePhotoVideoId, required i2.AssetVisibility visibility, + i0.Value stackId, }); typedef $$RemoteAssetEntityTableUpdateCompanionBuilder = i1.RemoteAssetEntityCompanion Function({ @@ -48,6 +49,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder i0.Value deletedAt, i0.Value livePhotoVideoId, i0.Value visibility, + i0.Value stackId, }); final class $$RemoteAssetEntityTableReferences extends i0.BaseReferences< @@ -145,6 +147,9 @@ class $$RemoteAssetEntityTableFilterComposer column: $table.visibility, builder: (column) => i0.ColumnWithTypeConverterFilters(column)); + i0.ColumnFilters get stackId => $composableBuilder( + column: $table.stackId, builder: (column) => i0.ColumnFilters(column)); + i5.$$UserEntityTableFilterComposer get ownerId { final i5.$$UserEntityTableFilterComposer composer = $composerBuilder( composer: this, @@ -231,6 +236,9 @@ class $$RemoteAssetEntityTableOrderingComposer column: $table.visibility, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get stackId => $composableBuilder( + column: $table.stackId, builder: (column) => i0.ColumnOrderings(column)); + i5.$$UserEntityTableOrderingComposer get ownerId { final i5.$$UserEntityTableOrderingComposer composer = $composerBuilder( composer: this, @@ -309,6 +317,9 @@ class $$RemoteAssetEntityTableAnnotationComposer $composableBuilder( column: $table.visibility, builder: (column) => column); + i0.GeneratedColumn get stackId => + $composableBuilder(column: $table.stackId, builder: (column) => column); + i5.$$UserEntityTableAnnotationComposer get ownerId { final i5.$$UserEntityTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -373,6 +384,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager< i0.Value deletedAt = const i0.Value.absent(), i0.Value livePhotoVideoId = const i0.Value.absent(), i0.Value visibility = const i0.Value.absent(), + i0.Value stackId = const i0.Value.absent(), }) => i1.RemoteAssetEntityCompanion( name: name, @@ -391,6 +403,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager< deletedAt: deletedAt, livePhotoVideoId: livePhotoVideoId, visibility: visibility, + stackId: stackId, ), createCompanionCallback: ({ required String name, @@ -409,6 +422,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager< i0.Value deletedAt = const i0.Value.absent(), i0.Value livePhotoVideoId = const i0.Value.absent(), required i2.AssetVisibility visibility, + i0.Value stackId = const i0.Value.absent(), }) => i1.RemoteAssetEntityCompanion.insert( name: name, @@ -427,6 +441,7 @@ class $$RemoteAssetEntityTableTableManager extends i0.RootTableManager< deletedAt: deletedAt, livePhotoVideoId: livePhotoVideoId, visibility: visibility, + stackId: stackId, ), withReferenceMapper: (p0) => p0 .map((e) => ( @@ -602,6 +617,12 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity type: i0.DriftSqlType.int, requiredDuringInsert: true) .withConverter( i1.$RemoteAssetEntityTable.$convertervisibility); + static const i0.VerificationMeta _stackIdMeta = + const i0.VerificationMeta('stackId'); + @override + late final i0.GeneratedColumn stackId = i0.GeneratedColumn( + 'stack_id', aliasedName, true, + type: i0.DriftSqlType.string, requiredDuringInsert: false); @override List get $columns => [ name, @@ -619,7 +640,8 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity thumbHash, deletedAt, livePhotoVideoId, - visibility + visibility, + stackId ]; @override String get aliasedName => _alias ?? actualTableName; @@ -703,6 +725,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity livePhotoVideoId.isAcceptableOrUnknown( data['live_photo_video_id']!, _livePhotoVideoIdMeta)); } + if (data.containsKey('stack_id')) { + context.handle(_stackIdMeta, + stackId.isAcceptableOrUnknown(data['stack_id']!, _stackIdMeta)); + } return context; } @@ -748,6 +774,8 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity visibility: i1.$RemoteAssetEntityTable.$convertervisibility.fromSql( attachedDatabase.typeMapping.read( i0.DriftSqlType.int, data['${effectivePrefix}visibility'])!), + stackId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}stack_id']), ); } @@ -785,6 +813,7 @@ class RemoteAssetEntityData extends i0.DataClass final DateTime? deletedAt; final String? livePhotoVideoId; final i2.AssetVisibility visibility; + final String? stackId; const RemoteAssetEntityData( {required this.name, required this.type, @@ -801,7 +830,8 @@ class RemoteAssetEntityData extends i0.DataClass this.thumbHash, this.deletedAt, this.livePhotoVideoId, - required this.visibility}); + required this.visibility, + this.stackId}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -841,6 +871,9 @@ class RemoteAssetEntityData extends i0.DataClass map['visibility'] = i0.Variable( i1.$RemoteAssetEntityTable.$convertervisibility.toSql(visibility)); } + if (!nullToAbsent || stackId != null) { + map['stack_id'] = i0.Variable(stackId); + } return map; } @@ -866,6 +899,7 @@ class RemoteAssetEntityData extends i0.DataClass livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), visibility: i1.$RemoteAssetEntityTable.$convertervisibility .fromJson(serializer.fromJson(json['visibility'])), + stackId: serializer.fromJson(json['stackId']), ); } @override @@ -890,6 +924,7 @@ class RemoteAssetEntityData extends i0.DataClass 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), 'visibility': serializer.toJson( i1.$RemoteAssetEntityTable.$convertervisibility.toJson(visibility)), + 'stackId': serializer.toJson(stackId), }; } @@ -909,7 +944,8 @@ class RemoteAssetEntityData extends i0.DataClass i0.Value thumbHash = const i0.Value.absent(), i0.Value deletedAt = const i0.Value.absent(), i0.Value livePhotoVideoId = const i0.Value.absent(), - i2.AssetVisibility? visibility}) => + i2.AssetVisibility? visibility, + i0.Value stackId = const i0.Value.absent()}) => i1.RemoteAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -932,6 +968,7 @@ class RemoteAssetEntityData extends i0.DataClass ? livePhotoVideoId.value : this.livePhotoVideoId, visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, ); RemoteAssetEntityData copyWithCompanion(i1.RemoteAssetEntityCompanion data) { return RemoteAssetEntityData( @@ -959,6 +996,7 @@ class RemoteAssetEntityData extends i0.DataClass : this.livePhotoVideoId, visibility: data.visibility.present ? data.visibility.value : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, ); } @@ -980,7 +1018,8 @@ class RemoteAssetEntityData extends i0.DataClass ..write('thumbHash: $thumbHash, ') ..write('deletedAt: $deletedAt, ') ..write('livePhotoVideoId: $livePhotoVideoId, ') - ..write('visibility: $visibility') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') ..write(')')) .toString(); } @@ -1002,7 +1041,8 @@ class RemoteAssetEntityData extends i0.DataClass thumbHash, deletedAt, livePhotoVideoId, - visibility); + visibility, + stackId); @override bool operator ==(Object other) => identical(this, other) || @@ -1022,7 +1062,8 @@ class RemoteAssetEntityData extends i0.DataClass other.thumbHash == this.thumbHash && other.deletedAt == this.deletedAt && other.livePhotoVideoId == this.livePhotoVideoId && - other.visibility == this.visibility); + other.visibility == this.visibility && + other.stackId == this.stackId); } class RemoteAssetEntityCompanion @@ -1043,6 +1084,7 @@ class RemoteAssetEntityCompanion final i0.Value deletedAt; final i0.Value livePhotoVideoId; final i0.Value visibility; + final i0.Value stackId; const RemoteAssetEntityCompanion({ this.name = const i0.Value.absent(), this.type = const i0.Value.absent(), @@ -1060,6 +1102,7 @@ class RemoteAssetEntityCompanion this.deletedAt = const i0.Value.absent(), this.livePhotoVideoId = const i0.Value.absent(), this.visibility = const i0.Value.absent(), + this.stackId = const i0.Value.absent(), }); RemoteAssetEntityCompanion.insert({ required String name, @@ -1078,6 +1121,7 @@ class RemoteAssetEntityCompanion this.deletedAt = const i0.Value.absent(), this.livePhotoVideoId = const i0.Value.absent(), required i2.AssetVisibility visibility, + this.stackId = const i0.Value.absent(), }) : name = i0.Value(name), type = i0.Value(type), id = i0.Value(id), @@ -1101,6 +1145,7 @@ class RemoteAssetEntityCompanion i0.Expression? deletedAt, i0.Expression? livePhotoVideoId, i0.Expression? visibility, + i0.Expression? stackId, }) { return i0.RawValuesInsertable({ if (name != null) 'name': name, @@ -1119,6 +1164,7 @@ class RemoteAssetEntityCompanion if (deletedAt != null) 'deleted_at': deletedAt, if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, }); } @@ -1138,7 +1184,8 @@ class RemoteAssetEntityCompanion i0.Value? thumbHash, i0.Value? deletedAt, i0.Value? livePhotoVideoId, - i0.Value? visibility}) { + i0.Value? visibility, + i0.Value? stackId}) { return i1.RemoteAssetEntityCompanion( name: name ?? this.name, type: type ?? this.type, @@ -1156,6 +1203,7 @@ class RemoteAssetEntityCompanion deletedAt: deletedAt ?? this.deletedAt, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, ); } @@ -1213,6 +1261,9 @@ class RemoteAssetEntityCompanion .$RemoteAssetEntityTable.$convertervisibility .toSql(visibility.value)); } + if (stackId.present) { + map['stack_id'] = i0.Variable(stackId.value); + } return map; } @@ -1234,7 +1285,8 @@ class RemoteAssetEntityCompanion ..write('thumbHash: $thumbHash, ') ..write('deletedAt: $deletedAt, ') ..write('livePhotoVideoId: $livePhotoVideoId, ') - ..write('visibility: $visibility') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') ..write(')')) .toString(); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 15d445d226..3b826c209b 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -7,27 +7,27 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift. as i2; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' as i3; -import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' - as i4; -import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' - as i5; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' - as i6; -import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' - as i7; -import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart' - as i8; -import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart' - as i9; -import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart' - as i10; -import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart' - as i11; -import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' - as i12; -import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart' - as i13; import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' + as i5; +import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' + as i6; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' + as i7; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' + as i8; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart' + as i9; +import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart' + as i10; +import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart' + as i11; +import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart' + as i12; +import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' + as i13; +import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart' as i14; import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' as i15; @@ -41,26 +41,26 @@ abstract class $Drift extends i0.GeneratedDatabase { i2.$RemoteAssetEntityTable(this); late final i3.$LocalAssetEntityTable localAssetEntity = i3.$LocalAssetEntityTable(this); - late final i4.$UserMetadataEntityTable userMetadataEntity = - i4.$UserMetadataEntityTable(this); - late final i5.$PartnerEntityTable partnerEntity = - i5.$PartnerEntityTable(this); - late final i6.$LocalAlbumEntityTable localAlbumEntity = - i6.$LocalAlbumEntityTable(this); - late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = - i7.$LocalAlbumAssetEntityTable(this); - late final i8.$RemoteExifEntityTable remoteExifEntity = - i8.$RemoteExifEntityTable(this); - late final i9.$RemoteAlbumEntityTable remoteAlbumEntity = - i9.$RemoteAlbumEntityTable(this); - late final i10.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = - i10.$RemoteAlbumAssetEntityTable(this); - late final i11.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = - i11.$RemoteAlbumUserEntityTable(this); - late final i12.$MemoryEntityTable memoryEntity = i12.$MemoryEntityTable(this); - late final i13.$MemoryAssetEntityTable memoryAssetEntity = - i13.$MemoryAssetEntityTable(this); - late final i14.$StackEntityTable stackEntity = i14.$StackEntityTable(this); + late final i4.$StackEntityTable stackEntity = i4.$StackEntityTable(this); + late final i5.$UserMetadataEntityTable userMetadataEntity = + i5.$UserMetadataEntityTable(this); + late final i6.$PartnerEntityTable partnerEntity = + i6.$PartnerEntityTable(this); + late final i7.$LocalAlbumEntityTable localAlbumEntity = + i7.$LocalAlbumEntityTable(this); + late final i8.$LocalAlbumAssetEntityTable localAlbumAssetEntity = + i8.$LocalAlbumAssetEntityTable(this); + late final i9.$RemoteExifEntityTable remoteExifEntity = + i9.$RemoteExifEntityTable(this); + late final i10.$RemoteAlbumEntityTable remoteAlbumEntity = + i10.$RemoteAlbumEntityTable(this); + late final i11.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = + i11.$RemoteAlbumAssetEntityTable(this); + late final i12.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = + i12.$RemoteAlbumUserEntityTable(this); + late final i13.$MemoryEntityTable memoryEntity = i13.$MemoryEntityTable(this); + late final i14.$MemoryAssetEntityTable memoryAssetEntity = + i14.$MemoryAssetEntityTable(this); i15.MergedAssetDrift get mergedAssetDrift => i16.ReadDatabaseContainer(this) .accessor(i15.MergedAssetDrift.new); @override @@ -71,6 +71,7 @@ abstract class $Drift extends i0.GeneratedDatabase { userEntity, remoteAssetEntity, localAssetEntity, + stackEntity, i3.idxLocalAssetChecksum, i2.uQRemoteAssetOwnerChecksum, i2.idxRemoteAssetChecksum, @@ -83,8 +84,7 @@ abstract class $Drift extends i0.GeneratedDatabase { remoteAlbumAssetEntity, remoteAlbumUserEntity, memoryEntity, - memoryAssetEntity, - stackEntity + memoryAssetEntity ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => @@ -97,6 +97,13 @@ abstract class $Drift extends i0.GeneratedDatabase { i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete), ], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('user_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete), + ], + ), i0.WritePropagation( on: i0.TableUpdateQuery.onTableName('user_entity', limitUpdateKind: i0.UpdateKind.delete), @@ -209,13 +216,6 @@ abstract class $Drift extends i0.GeneratedDatabase { i0.TableUpdate('memory_asset_entity', kind: i0.UpdateKind.delete), ], ), - i0.WritePropagation( - on: i0.TableUpdateQuery.onTableName('user_entity', - limitUpdateKind: i0.UpdateKind.delete), - result: [ - i0.TableUpdate('stack_entity', kind: i0.UpdateKind.delete), - ], - ), ], ); @override @@ -232,27 +232,27 @@ class $DriftManager { i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity); i3.$$LocalAssetEntityTableTableManager get localAssetEntity => i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); - i4.$$UserMetadataEntityTableTableManager get userMetadataEntity => - i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); - i5.$$PartnerEntityTableTableManager get partnerEntity => - i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); - i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity => - i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); - i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7 + i4.$$StackEntityTableTableManager get stackEntity => + i4.$$StackEntityTableTableManager(_db, _db.stackEntity); + i5.$$UserMetadataEntityTableTableManager get userMetadataEntity => + i5.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); + i6.$$PartnerEntityTableTableManager get partnerEntity => + i6.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); + i7.$$LocalAlbumEntityTableTableManager get localAlbumEntity => + i7.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); + i8.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i8 .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity); - i8.$$RemoteExifEntityTableTableManager get remoteExifEntity => - i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); - i9.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity => - i9.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity); - i10.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity => - i10.$$RemoteAlbumAssetEntityTableTableManager( + i9.$$RemoteExifEntityTableTableManager get remoteExifEntity => + i9.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); + i10.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity => + i10.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity); + i11.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity => + i11.$$RemoteAlbumAssetEntityTableTableManager( _db, _db.remoteAlbumAssetEntity); - i11.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i11 + i12.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i12 .$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity); - i12.$$MemoryEntityTableTableManager get memoryEntity => - i12.$$MemoryEntityTableTableManager(_db, _db.memoryEntity); - i13.$$MemoryAssetEntityTableTableManager get memoryAssetEntity => - i13.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity); - i14.$$StackEntityTableTableManager get stackEntity => - i14.$$StackEntityTableTableManager(_db, _db.stackEntity); + i13.$$MemoryEntityTableTableManager get memoryEntity => + i13.$$MemoryEntityTableTableManager(_db, _db.memoryEntity); + i14.$$MemoryAssetEntityTableTableManager get memoryAssetEntity => + i14.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 5815d31978..4b27ce830c 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -12,6 +12,7 @@ final class Schema2 extends i0.VersionedSchema { userEntity, remoteAssetEntity, localAssetEntity, + stackEntity, idxLocalAssetChecksum, uQRemoteAssetOwnerChecksum, idxRemoteAssetChecksum, @@ -25,7 +26,6 @@ final class Schema2 extends i0.VersionedSchema { remoteAlbumUserEntity, memoryEntity, memoryAssetEntity, - stackEntity, ]; late final Shape0 userEntity = Shape0( source: i0.VersionedTable( @@ -73,6 +73,7 @@ final class Schema2 extends i0.VersionedSchema { _column_18, _column_19, _column_20, + _column_21, ], attachedDatabase: database, ), @@ -94,9 +95,27 @@ final class Schema2 extends i0.VersionedSchema { _column_11, _column_12, _column_0, - _column_21, - _column_14, _column_22, + _column_14, + _column_23, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape3 stackEntity = Shape3( + source: i0.VersionedTable( + entityName: 'stack_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_24, ], attachedDatabase: database, ), @@ -108,7 +127,7 @@ final class Schema2 extends i0.VersionedSchema { 'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)'); final i1.Index idxRemoteAssetChecksum = i1.Index('idx_remote_asset_checksum', 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); - late final Shape3 userMetadataEntity = Shape3( + late final Shape4 userMetadataEntity = Shape4( source: i0.VersionedTable( entityName: 'user_metadata_entity', withoutRowId: true, @@ -117,14 +136,14 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(user_id, "key")', ], columns: [ - _column_23, - _column_24, _column_25, + _column_26, + _column_27, ], attachedDatabase: database, ), alias: null); - late final Shape4 partnerEntity = Shape4( + late final Shape5 partnerEntity = Shape5( source: i0.VersionedTable( entityName: 'partner_entity', withoutRowId: true, @@ -133,14 +152,14 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(shared_by_id, shared_with_id)', ], columns: [ - _column_26, - _column_27, _column_28, + _column_29, + _column_30, ], attachedDatabase: database, ), alias: null); - late final Shape5 localAlbumEntity = Shape5( + late final Shape6 localAlbumEntity = Shape6( source: i0.VersionedTable( entityName: 'local_album_entity', withoutRowId: true, @@ -152,14 +171,14 @@ final class Schema2 extends i0.VersionedSchema { _column_0, _column_1, _column_5, - _column_29, - _column_30, _column_31, + _column_32, + _column_33, ], attachedDatabase: database, ), alias: null); - late final Shape6 localAlbumAssetEntity = Shape6( + late final Shape7 localAlbumAssetEntity = Shape7( source: i0.VersionedTable( entityName: 'local_album_asset_entity', withoutRowId: true, @@ -168,13 +187,13 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(asset_id, album_id)', ], columns: [ - _column_32, - _column_33, + _column_34, + _column_35, ], attachedDatabase: database, ), alias: null); - late final Shape7 remoteExifEntity = Shape7( + late final Shape8 remoteExifEntity = Shape8( source: i0.VersionedTable( entityName: 'remote_exif_entity', withoutRowId: true, @@ -183,16 +202,14 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(asset_id)', ], columns: [ - _column_34, - _column_35, _column_36, _column_37, _column_38, _column_39, - _column_11, - _column_10, _column_40, _column_41, + _column_11, + _column_10, _column_42, _column_43, _column_44, @@ -205,11 +222,13 @@ final class Schema2 extends i0.VersionedSchema { _column_51, _column_52, _column_53, + _column_54, + _column_55, ], attachedDatabase: database, ), alias: null); - late final Shape8 remoteAlbumEntity = Shape8( + late final Shape9 remoteAlbumEntity = Shape9( source: i0.VersionedTable( entityName: 'remote_album_entity', withoutRowId: true, @@ -220,18 +239,18 @@ final class Schema2 extends i0.VersionedSchema { columns: [ _column_0, _column_1, - _column_54, + _column_56, _column_9, _column_5, _column_15, - _column_55, - _column_56, _column_57, + _column_58, + _column_59, ], attachedDatabase: database, ), alias: null); - late final Shape6 remoteAlbumAssetEntity = Shape6( + late final Shape7 remoteAlbumAssetEntity = Shape7( source: i0.VersionedTable( entityName: 'remote_album_asset_entity', withoutRowId: true, @@ -240,13 +259,13 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(asset_id, album_id)', ], columns: [ - _column_34, - _column_58, + _column_36, + _column_60, ], attachedDatabase: database, ), alias: null); - late final Shape9 remoteAlbumUserEntity = Shape9( + late final Shape10 remoteAlbumUserEntity = Shape10( source: i0.VersionedTable( entityName: 'remote_album_user_entity', withoutRowId: true, @@ -255,14 +274,14 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(album_id, user_id)', ], columns: [ - _column_58, - _column_23, - _column_59, + _column_60, + _column_25, + _column_61, ], attachedDatabase: database, ), alias: null); - late final Shape10 memoryEntity = Shape10( + late final Shape11 memoryEntity = Shape11( source: i0.VersionedTable( entityName: 'memory_entity', withoutRowId: true, @@ -277,17 +296,17 @@ final class Schema2 extends i0.VersionedSchema { _column_18, _column_15, _column_8, - _column_60, - _column_61, _column_62, _column_63, _column_64, _column_65, + _column_66, + _column_67, ], attachedDatabase: database, ), alias: null); - late final Shape11 memoryAssetEntity = Shape11( + late final Shape12 memoryAssetEntity = Shape12( source: i0.VersionedTable( entityName: 'memory_asset_entity', withoutRowId: true, @@ -296,26 +315,8 @@ final class Schema2 extends i0.VersionedSchema { 'PRIMARY KEY(asset_id, memory_id)', ], columns: [ - _column_34, - _column_66, - ], - attachedDatabase: database, - ), - alias: null); - late final Shape12 stackEntity = Shape12( - source: i0.VersionedTable( - entityName: 'stack_entity', - withoutRowId: true, - isStrict: true, - tableConstraints: [ - 'PRIMARY KEY(id)', - ], - columns: [ - _column_0, - _column_9, - _column_5, - _column_15, - _column_67, + _column_36, + _column_68, ], attachedDatabase: database, ), @@ -405,6 +406,8 @@ class Shape1 extends i0.VersionedTable { columnsByName['live_photo_video_id']! as i1.GeneratedColumn; i1.GeneratedColumn get visibility => columnsByName['visibility']! as i1.GeneratedColumn; + i1.GeneratedColumn get stackId => + columnsByName['stack_id']! as i1.GeneratedColumn; } i1.GeneratedColumn _column_8(String aliasedName) => @@ -452,6 +455,9 @@ i1.GeneratedColumn _column_19(String aliasedName) => i1.GeneratedColumn _column_20(String aliasedName) => i1.GeneratedColumn('visibility', aliasedName, false, type: i1.DriftSqlType.int); +i1.GeneratedColumn _column_21(String aliasedName) => + i1.GeneratedColumn('stack_id', aliasedName, true, + type: i1.DriftSqlType.string); class Shape2 extends i0.VersionedTable { Shape2({required super.source, required super.alias}) : super.aliased(); @@ -479,15 +485,35 @@ class Shape2 extends i0.VersionedTable { columnsByName['orientation']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_21(String aliasedName) => +i1.GeneratedColumn _column_22(String aliasedName) => i1.GeneratedColumn('checksum', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_22(String aliasedName) => +i1.GeneratedColumn _column_23(String aliasedName) => i1.GeneratedColumn('orientation', aliasedName, false, type: i1.DriftSqlType.int, defaultValue: const CustomExpression('0')); class Shape3 extends i0.VersionedTable { Shape3({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get ownerId => + columnsByName['owner_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get primaryAssetId => + columnsByName['primary_asset_id']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_24(String aliasedName) => + i1.GeneratedColumn('primary_asset_id', aliasedName, false, + type: i1.DriftSqlType.string, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id)')); + +class Shape4 extends i0.VersionedTable { + Shape4({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get userId => columnsByName['user_id']! as i1.GeneratedColumn; i1.GeneratedColumn get key => @@ -496,20 +522,20 @@ class Shape3 extends i0.VersionedTable { columnsByName['value']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_23(String aliasedName) => +i1.GeneratedColumn _column_25(String aliasedName) => i1.GeneratedColumn('user_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES user_entity (id) ON DELETE CASCADE')); -i1.GeneratedColumn _column_24(String aliasedName) => +i1.GeneratedColumn _column_26(String aliasedName) => i1.GeneratedColumn('key', aliasedName, false, type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_25(String aliasedName) => +i1.GeneratedColumn _column_27(String aliasedName) => i1.GeneratedColumn('value', aliasedName, false, type: i1.DriftSqlType.blob); -class Shape4 extends i0.VersionedTable { - Shape4({required super.source, required super.alias}) : super.aliased(); +class Shape5 extends i0.VersionedTable { + Shape5({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get sharedById => columnsByName['shared_by_id']! as i1.GeneratedColumn; i1.GeneratedColumn get sharedWithId => @@ -518,25 +544,25 @@ class Shape4 extends i0.VersionedTable { columnsByName['in_timeline']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_26(String aliasedName) => +i1.GeneratedColumn _column_28(String aliasedName) => i1.GeneratedColumn('shared_by_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES user_entity (id) ON DELETE CASCADE')); -i1.GeneratedColumn _column_27(String aliasedName) => +i1.GeneratedColumn _column_29(String aliasedName) => i1.GeneratedColumn('shared_with_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES user_entity (id) ON DELETE CASCADE')); -i1.GeneratedColumn _column_28(String aliasedName) => +i1.GeneratedColumn _column_30(String aliasedName) => i1.GeneratedColumn('in_timeline', aliasedName, false, type: i1.DriftSqlType.bool, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("in_timeline" IN (0, 1))'), defaultValue: const CustomExpression('0')); -class Shape5 extends i0.VersionedTable { - Shape5({required super.source, required super.alias}) : super.aliased(); +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; i1.GeneratedColumn get name => @@ -551,42 +577,42 @@ class Shape5 extends i0.VersionedTable { columnsByName['marker']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_29(String aliasedName) => +i1.GeneratedColumn _column_31(String aliasedName) => i1.GeneratedColumn('backup_selection', aliasedName, false, type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_30(String aliasedName) => +i1.GeneratedColumn _column_32(String aliasedName) => i1.GeneratedColumn('is_ios_shared_album', aliasedName, false, type: i1.DriftSqlType.bool, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("is_ios_shared_album" IN (0, 1))'), defaultValue: const CustomExpression('0')); -i1.GeneratedColumn _column_31(String aliasedName) => +i1.GeneratedColumn _column_33(String aliasedName) => i1.GeneratedColumn('marker', aliasedName, true, type: i1.DriftSqlType.bool, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("marker" IN (0, 1))')); -class Shape6 extends i0.VersionedTable { - Shape6({required super.source, required super.alias}) : super.aliased(); +class Shape7 extends i0.VersionedTable { + Shape7({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get assetId => columnsByName['asset_id']! as i1.GeneratedColumn; i1.GeneratedColumn get albumId => columnsByName['album_id']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_32(String aliasedName) => +i1.GeneratedColumn _column_34(String aliasedName) => i1.GeneratedColumn('asset_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES local_asset_entity (id) ON DELETE CASCADE')); -i1.GeneratedColumn _column_33(String aliasedName) => +i1.GeneratedColumn _column_35(String aliasedName) => i1.GeneratedColumn('album_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES local_album_entity (id) ON DELETE CASCADE')); -class Shape7 extends i0.VersionedTable { - Shape7({required super.source, required super.alias}) : super.aliased(); +class Shape8 extends i0.VersionedTable { + Shape8({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get assetId => columnsByName['asset_id']! as i1.GeneratedColumn; i1.GeneratedColumn get city => @@ -633,71 +659,71 @@ class Shape7 extends i0.VersionedTable { columnsByName['projection_type']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_34(String aliasedName) => +i1.GeneratedColumn _column_36(String aliasedName) => i1.GeneratedColumn('asset_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); -i1.GeneratedColumn _column_35(String aliasedName) => +i1.GeneratedColumn _column_37(String aliasedName) => i1.GeneratedColumn('city', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_36(String aliasedName) => +i1.GeneratedColumn _column_38(String aliasedName) => i1.GeneratedColumn('state', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_37(String aliasedName) => +i1.GeneratedColumn _column_39(String aliasedName) => i1.GeneratedColumn('country', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_38(String aliasedName) => +i1.GeneratedColumn _column_40(String aliasedName) => i1.GeneratedColumn('date_time_original', aliasedName, true, type: i1.DriftSqlType.dateTime); -i1.GeneratedColumn _column_39(String aliasedName) => +i1.GeneratedColumn _column_41(String aliasedName) => i1.GeneratedColumn('description', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_40(String aliasedName) => +i1.GeneratedColumn _column_42(String aliasedName) => i1.GeneratedColumn('exposure_time', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_41(String aliasedName) => +i1.GeneratedColumn _column_43(String aliasedName) => i1.GeneratedColumn('f_number', aliasedName, true, type: i1.DriftSqlType.double); -i1.GeneratedColumn _column_42(String aliasedName) => +i1.GeneratedColumn _column_44(String aliasedName) => i1.GeneratedColumn('file_size', aliasedName, true, type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_43(String aliasedName) => +i1.GeneratedColumn _column_45(String aliasedName) => i1.GeneratedColumn('focal_length', aliasedName, true, type: i1.DriftSqlType.double); -i1.GeneratedColumn _column_44(String aliasedName) => +i1.GeneratedColumn _column_46(String aliasedName) => i1.GeneratedColumn('latitude', aliasedName, true, type: i1.DriftSqlType.double); -i1.GeneratedColumn _column_45(String aliasedName) => +i1.GeneratedColumn _column_47(String aliasedName) => i1.GeneratedColumn('longitude', aliasedName, true, type: i1.DriftSqlType.double); -i1.GeneratedColumn _column_46(String aliasedName) => +i1.GeneratedColumn _column_48(String aliasedName) => i1.GeneratedColumn('iso', aliasedName, true, type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_47(String aliasedName) => +i1.GeneratedColumn _column_49(String aliasedName) => i1.GeneratedColumn('make', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_48(String aliasedName) => +i1.GeneratedColumn _column_50(String aliasedName) => i1.GeneratedColumn('model', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_49(String aliasedName) => +i1.GeneratedColumn _column_51(String aliasedName) => i1.GeneratedColumn('lens', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_50(String aliasedName) => +i1.GeneratedColumn _column_52(String aliasedName) => i1.GeneratedColumn('orientation', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_51(String aliasedName) => +i1.GeneratedColumn _column_53(String aliasedName) => i1.GeneratedColumn('time_zone', aliasedName, true, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_52(String aliasedName) => +i1.GeneratedColumn _column_54(String aliasedName) => i1.GeneratedColumn('rating', aliasedName, true, type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_53(String aliasedName) => +i1.GeneratedColumn _column_55(String aliasedName) => i1.GeneratedColumn('projection_type', aliasedName, true, type: i1.DriftSqlType.string); -class Shape8 extends i0.VersionedTable { - Shape8({required super.source, required super.alias}) : super.aliased(); +class Shape9 extends i0.VersionedTable { + Shape9({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; i1.GeneratedColumn get name => @@ -718,32 +744,32 @@ class Shape8 extends i0.VersionedTable { columnsByName['order']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_54(String aliasedName) => +i1.GeneratedColumn _column_56(String aliasedName) => i1.GeneratedColumn('description', aliasedName, false, type: i1.DriftSqlType.string, defaultValue: const CustomExpression('\'\'')); -i1.GeneratedColumn _column_55(String aliasedName) => +i1.GeneratedColumn _column_57(String aliasedName) => i1.GeneratedColumn('thumbnail_asset_id', aliasedName, true, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL')); -i1.GeneratedColumn _column_56(String aliasedName) => +i1.GeneratedColumn _column_58(String aliasedName) => i1.GeneratedColumn('is_activity_enabled', aliasedName, false, type: i1.DriftSqlType.bool, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("is_activity_enabled" IN (0, 1))'), defaultValue: const CustomExpression('1')); -i1.GeneratedColumn _column_57(String aliasedName) => +i1.GeneratedColumn _column_59(String aliasedName) => i1.GeneratedColumn('order', aliasedName, false, type: i1.DriftSqlType.int); -i1.GeneratedColumn _column_58(String aliasedName) => +i1.GeneratedColumn _column_60(String aliasedName) => i1.GeneratedColumn('album_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); -class Shape9 extends i0.VersionedTable { - Shape9({required super.source, required super.alias}) : super.aliased(); +class Shape10 extends i0.VersionedTable { + Shape10({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get albumId => columnsByName['album_id']! as i1.GeneratedColumn; i1.GeneratedColumn get userId => @@ -752,12 +778,12 @@ class Shape9 extends i0.VersionedTable { columnsByName['role']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_59(String aliasedName) => +i1.GeneratedColumn _column_61(String aliasedName) => i1.GeneratedColumn('role', aliasedName, false, type: i1.DriftSqlType.int); -class Shape10 extends i0.VersionedTable { - Shape10({required super.source, required super.alias}) : super.aliased(); +class Shape11 extends i0.VersionedTable { + Shape11({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get id => columnsByName['id']! as i1.GeneratedColumn; i1.GeneratedColumn get createdAt => @@ -784,61 +810,41 @@ class Shape10 extends i0.VersionedTable { columnsByName['hide_at']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_60(String aliasedName) => +i1.GeneratedColumn _column_62(String aliasedName) => i1.GeneratedColumn('data', aliasedName, false, type: i1.DriftSqlType.string); -i1.GeneratedColumn _column_61(String aliasedName) => +i1.GeneratedColumn _column_63(String aliasedName) => i1.GeneratedColumn('is_saved', aliasedName, false, type: i1.DriftSqlType.bool, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'CHECK ("is_saved" IN (0, 1))'), defaultValue: const CustomExpression('0')); -i1.GeneratedColumn _column_62(String aliasedName) => +i1.GeneratedColumn _column_64(String aliasedName) => i1.GeneratedColumn('memory_at', aliasedName, false, type: i1.DriftSqlType.dateTime); -i1.GeneratedColumn _column_63(String aliasedName) => +i1.GeneratedColumn _column_65(String aliasedName) => i1.GeneratedColumn('seen_at', aliasedName, true, type: i1.DriftSqlType.dateTime); -i1.GeneratedColumn _column_64(String aliasedName) => +i1.GeneratedColumn _column_66(String aliasedName) => i1.GeneratedColumn('show_at', aliasedName, true, type: i1.DriftSqlType.dateTime); -i1.GeneratedColumn _column_65(String aliasedName) => +i1.GeneratedColumn _column_67(String aliasedName) => i1.GeneratedColumn('hide_at', aliasedName, true, type: i1.DriftSqlType.dateTime); -class Shape11 extends i0.VersionedTable { - Shape11({required super.source, required super.alias}) : super.aliased(); +class Shape12 extends i0.VersionedTable { + Shape12({required super.source, required super.alias}) : super.aliased(); i1.GeneratedColumn get assetId => columnsByName['asset_id']! as i1.GeneratedColumn; i1.GeneratedColumn get memoryId => columnsByName['memory_id']! as i1.GeneratedColumn; } -i1.GeneratedColumn _column_66(String aliasedName) => +i1.GeneratedColumn _column_68(String aliasedName) => i1.GeneratedColumn('memory_id', aliasedName, false, type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES memory_entity (id) ON DELETE CASCADE')); - -class Shape12 extends i0.VersionedTable { - Shape12({required super.source, required super.alias}) : super.aliased(); - i1.GeneratedColumn get id => - columnsByName['id']! as i1.GeneratedColumn; - i1.GeneratedColumn get createdAt => - columnsByName['created_at']! as i1.GeneratedColumn; - i1.GeneratedColumn get updatedAt => - columnsByName['updated_at']! as i1.GeneratedColumn; - i1.GeneratedColumn get ownerId => - columnsByName['owner_id']! as i1.GeneratedColumn; - i1.GeneratedColumn get primaryAssetId => - columnsByName['primary_asset_id']! as i1.GeneratedColumn; -} - -i1.GeneratedColumn _column_67(String aliasedName) => - i1.GeneratedColumn('primary_asset_id', aliasedName, false, - type: i1.DriftSqlType.string, - defaultConstraints: i1.GeneratedColumn.constraintIsAlways( - 'REFERENCES remote_asset_entity (id)')); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, }) { diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 1f6f1b0891..28c229b46b 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,11 +1,13 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -30,25 +32,66 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } Stream watchAsset(String id) { - final query = _db.remoteAssetEntity - .select() - .addColumns([_db.localAssetEntity.id]).join([ + final stackCountRef = _db.stackEntity.id.count(); + + final query = _db.remoteAssetEntity.select().addColumns([ + _db.localAssetEntity.id, + _db.stackEntity.primaryAssetId, + stackCountRef, + ]).join([ leftOuterJoin( _db.localAssetEntity, _db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum), useColumns: false, ), + leftOuterJoin( + _db.stackEntity, + _db.stackEntity.primaryAssetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity.createAlias('stacked_assets'), + _db.stackEntity.id.equalsExp( + _db.remoteAssetEntity.createAlias('stacked_assets').stackId, + ), + useColumns: false, + ), ]) - ..where(_db.remoteAssetEntity.id.equals(id)); + ..where(_db.remoteAssetEntity.id.equals(id)) + ..groupBy([ + _db.remoteAssetEntity.id, + _db.localAssetEntity.id, + _db.stackEntity.primaryAssetId, + ]); return query.map((row) { final asset = row.readTable(_db.remoteAssetEntity).toDto(); + final primaryAssetId = row.read(_db.stackEntity.primaryAssetId); + final stackCount = + primaryAssetId == id ? (row.read(stackCountRef) ?? 0) : 0; + return asset.copyWith( localId: row.read(_db.localAssetEntity.id), + stackCount: stackCount, ); }).watchSingleOrNull(); } + Future> getStackChildren(RemoteAsset asset) { + if (asset.stackId == null) { + return Future.value([]); + } + + final query = _db.remoteAssetEntity.select() + ..where( + (row) => + row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not(), + ) + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); + + return query.map((row) => row.toDto()).get(); + } + Future getExif(String id) { return _db.managers.remoteExifEntity .filter((row) => row.assetId.id.equals(id)) @@ -146,4 +189,53 @@ class RemoteAssetRepository extends DriftDatabaseRepository { } }); } + + Future stack(String userId, StackResponse stack) { + return _db.transaction(() async { + final stackIds = await _db.managers.stackEntity + .filter((row) => row.primaryAssetId.id.isIn(stack.assetIds)) + .map((row) => row.id) + .get(); + + await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); + + await _db.batch((batch) { + final companion = StackEntityCompanion( + ownerId: Value(userId), + primaryAssetId: Value(stack.primaryAssetId), + ); + + batch.insert( + _db.stackEntity, + companion.copyWith(id: Value(stack.id)), + onConflict: DoUpdate((_) => companion), + ); + + for (final assetId in stack.assetIds) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion( + stackId: Value(stack.id), + ), + where: (e) => e.id.equals(assetId), + ); + } + }); + }); + } + + Future unStack(List stackIds) { + return _db.transaction(() async { + await _db.stackEntity.deleteWhere((row) => row.id.isIn(stackIds)); + + // TODO: delete this after adding foreign key on stackId + await _db.batch((batch) { + batch.update( + _db.remoteAssetEntity, + const RemoteAssetEntityCompanion(stackId: Value(null)), + where: (e) => e.stackId.isIn(stackIds), + ); + }); + }); + } } diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index f3f26bb01f..d28c68ed78 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -137,6 +137,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { deletedAt: Value(asset.deletedAt), visibility: Value(asset.visibility.toAssetVisibility()), livePhotoVideoId: Value(asset.livePhotoVideoId), + stackId: Value(asset.stackId), ); batch.insert( diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index a6d89b5e8e..0c3eee59af 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -89,6 +89,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository { isFavorite: row.isFavorite, durationInSeconds: row.durationInSeconds, livePhotoVideoId: row.livePhotoVideoId, + stackId: row.stackId, + stackCount: row.stackCount, ) : LocalAsset( id: row.localId!, diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart index 7216b638e1..0582399eaf 100644 --- a/mobile/lib/presentation/pages/dev/main_timeline.page.dart +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -13,7 +13,7 @@ class MainTimelinePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final memoryLaneProvider = ref.watch(driftMemoryFutureProvider); - return memoryLaneProvider.when( + return memoryLaneProvider.maybeWhen( data: (memories) { return memories.isEmpty ? const Timeline(showStorageIndicator: true) @@ -26,8 +26,7 @@ class MainTimelinePage extends ConsumerWidget { showStorageIndicator: true, ); }, - loading: () => const Timeline(showStorageIndicator: true), - error: (error, stackTrace) => const Timeline(showStorageIndicator: true), + orElse: () => const Timeline(showStorageIndicator: true), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart index dc42eb96f1..13782c0098 100644 --- a/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/stack_action_button.widget.dart @@ -1,16 +1,56 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class StackActionButton extends ConsumerWidget { - const StackActionButton({super.key}); + final ActionSource source; + + const StackActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final user = ref.watch(currentUserProvider); + if (user == null) { + throw Exception('User must be logged in to access stack action'); + } + + final result = + await ref.read(actionProvider.notifier).stack(user.id, source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'stack_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.filter_none_rounded, label: "stack".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart new file mode 100644 index 0000000000..c2757043a3 --- /dev/null +++ b/mobile/lib/presentation/widgets/action_buttons/unstack_action_button.widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class UnStackActionButton extends ConsumerWidget { + final ActionSource source; + + const UnStackActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).unStack(source); + ref.read(multiSelectProvider.notifier).reset(); + + final successMessage = 'unstack_action_prompt'.t( + context: context, + args: {'count': result.count.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: result.success + ? successMessage + : 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: result.success ? ToastType.success : ToastType.error, + ); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return BaseActionButton( + iconData: Icons.filter_none_rounded, + label: "unstack".t(context: context), + onPressed: () => _onTap(context, ref), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart new file mode 100644 index 0000000000..cb4e02b56c --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.provider.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; + +class StackChildrenNotifier + extends AutoDisposeFamilyAsyncNotifier, BaseAsset?> { + @override + Future> build(BaseAsset? asset) async { + if (asset == null || + asset is! RemoteAsset || + asset.stackId == null || + // The stackCount check is to ensure we only fetch stacks for timelines that have stacks + asset.stackCount == 0) { + return const []; + } + + return ref.watch(assetServiceProvider).getStack(asset); + } +} + +final stackChildrenNotifier = AsyncNotifierProvider.autoDispose + .family, BaseAsset?>( + StackChildrenNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart new file mode 100644 index 0000000000..8b3d0c6575 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_stack.widget.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; + +class AssetStackRow extends ConsumerWidget { + const AssetStackRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + int opacity = ref.watch( + assetViewerProvider.select((state) => state.backgroundOpacity), + ); + final showControls = + ref.watch(assetViewerProvider.select((s) => s.showingControls)); + + if (!showControls) { + opacity = 0; + } + + final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); + + return IgnorePointer( + ignoring: opacity < 255, + child: AnimatedOpacity( + opacity: opacity / 255, + duration: Durations.short2, + child: ref.watch(stackChildrenNotifier(asset)).when( + data: (state) => SizedBox.square( + dimension: 80, + child: _StackList(stack: state), + ), + error: (_, __) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), + ), + ); + } +} + +class _StackList extends ConsumerWidget { + final List stack; + + const _StackList({required this.stack}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only( + left: 5, + right: 5, + bottom: 30, + ), + itemCount: stack.length, + itemBuilder: (ctx, index) { + final asset = stack[index]; + return Padding( + padding: const EdgeInsets.only(right: 5), + child: GestureDetector( + onTap: () { + ref.read(assetViewerProvider.notifier).setStackIndex(index); + ref.read(currentAssetNotifier.notifier).setAsset(asset); + }, + child: Container( + height: 60, + width: 60, + decoration: index == + ref.watch(assetViewerProvider.select((s) => s.stackIndex)) + ? const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide( + BorderSide(color: Colors.white, width: 2), + ), + ) + : const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(6)), + border: null, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: Stack( + fit: StackFit.expand, + children: [ + Image( + fit: BoxFit.cover, + image: getThumbnailImageProvider( + remoteId: asset.id, + size: const Size.square(60), + ), + ), + if (asset.isVideo) + const Icon( + Icons.play_circle_outline_rounded, + color: Colors.white, + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + offset: Offset(0.0, 0.0), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 1c0f28413a..50f4a09197 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -5,10 +5,13 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart'; @@ -85,6 +88,7 @@ class _AssetViewerState extends ConsumerState { double previousExtent = _kBottomSheetMinimumExtent; Offset dragDownPosition = Offset.zero; int totalAssets = 0; + int stackIndex = 0; BuildContext? scaffoldContext; Map videoPlayerKeys = {}; @@ -167,6 +171,10 @@ class _AssetViewerState extends ConsumerState { void _onAssetChanged(int index) { final asset = ref.read(timelineServiceProvider).getAsset(index); + // Always holds the current asset from the timeline + ref.read(assetViewerProvider.notifier).setAsset(asset); + // The currentAssetNotifier actually holds the current asset that is displayed + // which could be stack children as well ref.read(currentAssetNotifier.notifier).setAsset(asset); if (asset.isVideo || asset.isMotionPhoto) { ref.read(videoPlaybackValueProvider.notifier).reset(); @@ -488,7 +496,12 @@ class _AssetViewerState extends ConsumerState { ImageChunkEvent? progress, int index, ) { - final asset = ref.read(timelineServiceProvider).getAsset(index); + BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); + final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + asset = stackChildren + .elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); + } return Container( width: double.infinity, height: double.infinity, @@ -516,9 +529,14 @@ class _AssetViewerState extends ConsumerState { PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { scaffoldContext ??= ctx; - final asset = ref.read(timelineServiceProvider).getAsset(index); - final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); + BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index); + final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; + if (stackChildren != null && stackChildren.isNotEmpty) { + asset = stackChildren + .elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex))); + } + final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider); if (asset.isImage && !isPlayingMotionVideo) { return _imageBuilder(ctx, asset); } @@ -604,6 +622,7 @@ class _AssetViewerState extends ConsumerState { // Using multiple selectors to avoid unnecessary rebuilds for other state changes ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet)); ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity)); + ref.watch(assetViewerProvider.select((s) => s.stackIndex)); ref.watch(isPlayingMotionVideoProvider); // Listen for casting changes and send initial asset to the cast provider @@ -645,7 +664,17 @@ class _AssetViewerState extends ConsumerState { backgroundDecoration: BoxDecoration(color: backgroundColor), enablePanAlways: true, ), - bottomNavigationBar: const ViewerBottomBar(), + bottomNavigationBar: showingBottomSheet + ? const SizedBox.shrink() + : const Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AssetStackRow(), + ViewerBottomBar(), + ], + ), ), ); } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart index 020d1d9b2c..825b637e8d 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.state.dart @@ -1,26 +1,40 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +class ViewerOpenBottomSheetEvent extends Event { + const ViewerOpenBottomSheetEvent(); +} + class AssetViewerState { final int backgroundOpacity; final bool showingBottomSheet; final bool showingControls; + final BaseAsset? currentAsset; + final int stackIndex; const AssetViewerState({ this.backgroundOpacity = 255, this.showingBottomSheet = false, this.showingControls = true, + this.currentAsset, + this.stackIndex = 0, }); AssetViewerState copyWith({ int? backgroundOpacity, bool? showingBottomSheet, bool? showingControls, + BaseAsset? currentAsset, + int? stackIndex, }) { return AssetViewerState( backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity, showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet, showingControls: showingControls ?? this.showingControls, + currentAsset: currentAsset ?? this.currentAsset, + stackIndex: stackIndex ?? this.stackIndex, ); } @@ -36,14 +50,18 @@ class AssetViewerState { return other is AssetViewerState && other.backgroundOpacity == backgroundOpacity && other.showingBottomSheet == showingBottomSheet && - other.showingControls == showingControls; + other.showingControls == showingControls && + other.currentAsset == currentAsset && + other.stackIndex == stackIndex; } @override int get hashCode => backgroundOpacity.hashCode ^ showingBottomSheet.hashCode ^ - showingControls.hashCode; + showingControls.hashCode ^ + currentAsset.hashCode ^ + stackIndex.hashCode; } class AssetViewerStateNotifier extends AutoDisposeNotifier { @@ -52,6 +70,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { return const AssetViewerState(); } + void setAsset(BaseAsset? asset) { + state = state.copyWith(currentAsset: asset, stackIndex: 0); + } + void setOpacity(int opacity) { state = state.copyWith( backgroundOpacity: opacity, @@ -76,6 +98,10 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier { void toggleControls() { state = state.copyWith(showingControls: !state.showingControls); } + + void setStackIndex(int index) { + state = state.copyWith(stackIndex: index); + } } final assetViewerProvider = diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index 7b1175e8be..a3d24ec8ee 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index 0615a857a9..eefe19194c 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index 61414252da..f9a9dd3203 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -49,7 +49,7 @@ class GeneralBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index c2d0d5c85a..b5fecdf7a4 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -52,7 +52,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { const MoveToLockFolderActionButton( source: ActionSource.timeline, ), - const StackActionButton(), + const StackActionButton(source: ActionSource.timeline), ], if (multiselect.hasLocal) ...[ const DeleteLocalActionButton(source: ActionSource.timeline), diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 7e3776adb2..ce3d39629f 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -53,6 +53,9 @@ class ThumbnailTile extends ConsumerWidget { ) : const BoxDecoration(); + final hasStack = + asset is RemoteAsset && (asset as RemoteAsset).stackCount > 0; + return Stack( children: [ AnimatedContainer( @@ -75,6 +78,19 @@ class ThumbnailTile extends ConsumerWidget { ), ), ), + if (hasStack) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: EdgeInsets.only( + right: 10.0, + top: asset.isVideo ? 24.0 : 6.0, + ), + child: _StackIndicator( + stackCount: (asset as RemoteAsset).stackCount, + ), + ), + ), if (asset.isVideo) Align( alignment: Alignment.topRight, @@ -182,6 +198,40 @@ class _SelectionIndicator extends StatelessWidget { } } +class _StackIndicator extends StatelessWidget { + final int stackCount; + + const _StackIndicator({required this.stackCount}); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 3, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + stackCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Color.fromRGBO(0, 0, 0, 0.6), + ), + ], + ), + ), + const _TileOverlayIcon(Icons.burst_mode_rounded), + ], + ); + } +} + class _VideoIndicator extends StatelessWidget { final Duration duration; const _VideoIndicator(this.duration); @@ -192,8 +242,8 @@ class _VideoIndicator extends StatelessWidget { spacing: 3, mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, - // CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center - crossAxisAlignment: CrossAxisAlignment.end, + // CrossAxisAlignment.start looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( duration.format(), diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 91003fb1a1..6faa4da9f7 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -15,6 +15,7 @@ class TimelineArgs { final double spacing; final int columnCount; final bool showStorageIndicator; + final bool withStack; final GroupAssetsBy? groupBy; const TimelineArgs({ @@ -23,6 +24,7 @@ class TimelineArgs { this.spacing = kTimelineSpacing, this.columnCount = kTimelineColumnCount, this.showStorageIndicator = false, + this.withStack = false, this.groupBy, }); @@ -33,6 +35,7 @@ class TimelineArgs { maxHeight == other.maxHeight && columnCount == other.columnCount && showStorageIndicator == other.showStorageIndicator && + withStack == other.withStack && groupBy == other.groupBy; } @@ -43,6 +46,7 @@ class TimelineArgs { spacing.hashCode ^ columnCount.hashCode ^ showStorageIndicator.hashCode ^ + withStack.hashCode ^ groupBy.hashCode; } diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index d279937417..873908832b 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -28,6 +28,7 @@ class Timeline extends StatelessWidget { this.topSliverWidget, this.topSliverWidgetHeight, this.showStorageIndicator = false, + this.withStack = false, this.appBar = const ImmichSliverAppBar( floating: true, pinned: false, @@ -42,6 +43,7 @@ class Timeline extends StatelessWidget { final bool showStorageIndicator; final Widget? appBar; final Widget? bottomSheet; + final bool withStack; final GroupAssetsBy? groupBy; @override @@ -58,6 +60,7 @@ class Timeline extends StatelessWidget { settingsProvider.select((s) => s.get(Setting.tilesPerRow)), ), showStorageIndicator: showStorageIndicator, + withStack: withStack, groupBy: groupBy, ), ), diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index b53417d021..456b072a39 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -50,7 +50,7 @@ class ActionNotifier extends Notifier { return _getIdsForSource(source).toIds().toList(growable: false); } - List _getOwnedRemoteForSource(ActionSource source) { + List _getOwnedRemoteIdsForSource(ActionSource source) { final ownerId = ref.read(currentUserProvider)?.id; return _getIdsForSource(source) .ownedAssets(ownerId) @@ -58,6 +58,20 @@ class ActionNotifier extends Notifier { .toList(growable: false); } + List _getOwnedRemoteAssetsForSource(ActionSource source) { + final ownerId = ref.read(currentUserProvider)?.id; + return _getIdsForSource(source).ownedAssets(ownerId).toList(); + } + + Iterable _getIdsForSource(ActionSource source) { + final Set assets = _getAssets(source); + return switch (T) { + const (RemoteAsset) => assets.whereType(), + const (LocalAsset) => assets.whereType(), + _ => const [], + } as Iterable; + } + Set _getAssets(ActionSource source) { return switch (source) { ActionSource.timeline => ref.read(multiSelectProvider).selectedAssets, @@ -68,15 +82,6 @@ class ActionNotifier extends Notifier { }; } - Iterable _getIdsForSource(ActionSource source) { - final Set assets = _getAssets(source); - return switch (T) { - const (RemoteAsset) => assets.whereType(), - const (LocalAsset) => assets.whereType(), - _ => const [], - } as Iterable; - } - Future shareLink( ActionSource source, BuildContext context, @@ -96,7 +101,7 @@ class ActionNotifier extends Notifier { } Future favorite(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.favorite(ids); return ActionResult(count: ids.length, success: true); @@ -111,7 +116,7 @@ class ActionNotifier extends Notifier { } Future unFavorite(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.unFavorite(ids); return ActionResult(count: ids.length, success: true); @@ -126,7 +131,7 @@ class ActionNotifier extends Notifier { } Future archive(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.archive(ids); return ActionResult(count: ids.length, success: true); @@ -141,7 +146,7 @@ class ActionNotifier extends Notifier { } Future unArchive(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.unArchive(ids); return ActionResult(count: ids.length, success: true); @@ -156,7 +161,7 @@ class ActionNotifier extends Notifier { } Future moveToLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.moveToLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -171,7 +176,7 @@ class ActionNotifier extends Notifier { } Future removeFromLockFolder(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.removeFromLockFolder(ids); return ActionResult(count: ids.length, success: true); @@ -186,7 +191,7 @@ class ActionNotifier extends Notifier { } Future trash(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.trash(ids); return ActionResult(count: ids.length, success: true); @@ -201,7 +206,7 @@ class ActionNotifier extends Notifier { } Future delete(ActionSource source) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { await _service.delete(ids); return ActionResult(count: ids.length, success: true); @@ -234,7 +239,7 @@ class ActionNotifier extends Notifier { ActionSource source, BuildContext context, ) async { - final ids = _getOwnedRemoteForSource(source); + final ids = _getOwnedRemoteIdsForSource(source); try { final isEdited = await _service.editLocation(ids, context); if (!isEdited) { @@ -270,6 +275,35 @@ class ActionNotifier extends Notifier { } } + Future stack(String userId, ActionSource source) async { + final ids = _getOwnedRemoteIdsForSource(source); + try { + await _service.stack(userId, ids); + return ActionResult(count: ids.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to stack assets', error, stack); + return ActionResult( + count: ids.length, + success: false, + error: error.toString(), + ); + } + } + + Future unStack(ActionSource source) async { + final assets = _getOwnedRemoteAssetsForSource(source); + try { + await _service.unStack(assets.map((e) => e.stackId).nonNulls.toList()); + return ActionResult(count: assets.length, success: true); + } catch (error, stack) { + _logger.severe('Failed to unstack assets', error, stack); + return ActionResult( + count: assets.length, + success: false, + ); + } + } + Future shareAssets(ActionSource source) async { final ids = _getAssets(source).toList(growable: false); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index cd49369a2a..4c854973b1 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/stack.model.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/api.repository.dart'; @@ -11,14 +12,16 @@ final assetApiRepositoryProvider = Provider( (ref) => AssetApiRepository( ref.watch(apiServiceProvider).assetsApi, ref.watch(apiServiceProvider).searchApi, + ref.watch(apiServiceProvider).stacksApi, ), ); class AssetApiRepository extends ApiRepository { final AssetsApi _api; final SearchApi _searchApi; + final StacksApi _stacksApi; - AssetApiRepository(this._api, this._searchApi); + AssetApiRepository(this._api, this._searchApi, this._stacksApi); Future update(String id, {String? description}) async { final response = await checkNull( @@ -84,6 +87,17 @@ class AssetApiRepository extends ApiRepository { ); } + Future stack(List ids) async { + final responseDto = + await checkNull(_stacksApi.createStack(StackCreateDto(assetIds: ids))); + + return responseDto.toStack(); + } + + Future unStack(List ids) async { + return _stacksApi.deleteStacks(BulkIdsDto(ids: ids)); + } + Future downloadAsset(String id) { return _api.downloadAssetWithHttpInfo(id); } @@ -102,3 +116,13 @@ class AssetApiRepository extends ApiRepository { return response.originalMimeType; } } + +extension on StackResponseDto { + StackResponse toStack() { + return StackResponse( + id: id, + primaryAssetId: primaryAssetId, + assetIds: assets.map((asset) => asset.id).toList(), + ); + } +} diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index 9559c5d316..adefd5da16 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -166,6 +166,16 @@ class ActionService { return removedCount; } + Future stack(String userId, List remoteIds) async { + final stack = await _assetApiRepository.stack(remoteIds); + await _remoteAssetRepository.stack(userId, stack); + } + + Future unStack(List stackIds) async { + await _remoteAssetRepository.unStack(stackIds); + await _assetApiRepository.unStack(stackIds); + } + Future shareAssets(List assets) { return _assetMediaRepository.shareAssets(assets); } diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index faaccfa51a..eace57fe5c 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart index 2d35a6702b..41eed09d8c 100644 --- a/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/remote_album_sliver_app_bar.dart @@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; diff --git a/mobile/lib/widgets/map/map_thumbnail.dart b/mobile/lib/widgets/map/map_thumbnail.dart index 06935cd4b5..1e4b061be6 100644 --- a/mobile/lib/widgets/map/map_thumbnail.dart +++ b/mobile/lib/widgets/map/map_thumbnail.dart @@ -63,8 +63,14 @@ class MapThumbnail extends HookConsumerWidget { } Future onStyleLoaded() async { - if (showMarkerPin && controller.value != null) { - await controller.value?.addMarkerAtLatLng(centre); + try { + if (showMarkerPin && controller.value != null) { + await controller.value?.addMarkerAtLatLng(centre); + } + } finally { + // Calling methods on the controller after it is disposed will throw an error + // We do not have a way to check if the controller is disposed for now + // https://github.com/maplibre/flutter-maplibre-gl/issues/192 } styleLoaded.value = true; } diff --git a/mobile/test/drift/main/generated/schema_v1.dart b/mobile/test/drift/main/generated/schema_v1.dart index 0172201921..38dbb9f827 100644 --- a/mobile/test/drift/main/generated/schema_v1.dart +++ b/mobile/test/drift/main/generated/schema_v1.dart @@ -410,9 +410,15 @@ class RemoteAssetEntity extends Table late final GeneratedColumn deletedAt = GeneratedColumn( 'deleted_at', aliasedName, true, type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); late final GeneratedColumn visibility = GeneratedColumn( 'visibility', aliasedName, false, type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); @override List get $columns => [ name, @@ -429,7 +435,9 @@ class RemoteAssetEntity extends Table localDateTime, thumbHash, deletedAt, - visibility + livePhotoVideoId, + visibility, + stackId ]; @override String get aliasedName => _alias ?? actualTableName; @@ -470,8 +478,12 @@ class RemoteAssetEntity extends Table .read(DriftSqlType.string, data['${effectivePrefix}thumb_hash']), deletedAt: attachedDatabase.typeMapping .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}live_photo_video_id']), visibility: attachedDatabase.typeMapping .read(DriftSqlType.int, data['${effectivePrefix}visibility'])!, + stackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}stack_id']), ); } @@ -502,7 +514,9 @@ class RemoteAssetEntityData extends DataClass final DateTime? localDateTime; final String? thumbHash; final DateTime? deletedAt; + final String? livePhotoVideoId; final int visibility; + final String? stackId; const RemoteAssetEntityData( {required this.name, required this.type, @@ -518,7 +532,9 @@ class RemoteAssetEntityData extends DataClass this.localDateTime, this.thumbHash, this.deletedAt, - required this.visibility}); + this.livePhotoVideoId, + required this.visibility, + this.stackId}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -548,7 +564,13 @@ class RemoteAssetEntityData extends DataClass if (!nullToAbsent || deletedAt != null) { map['deleted_at'] = Variable(deletedAt); } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } return map; } @@ -570,7 +592,9 @@ class RemoteAssetEntityData extends DataClass localDateTime: serializer.fromJson(json['localDateTime']), thumbHash: serializer.fromJson(json['thumbHash']), deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), ); } @override @@ -591,7 +615,9 @@ class RemoteAssetEntityData extends DataClass 'localDateTime': serializer.toJson(localDateTime), 'thumbHash': serializer.toJson(thumbHash), 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), }; } @@ -610,7 +636,9 @@ class RemoteAssetEntityData extends DataClass Value localDateTime = const Value.absent(), Value thumbHash = const Value.absent(), Value deletedAt = const Value.absent(), - int? visibility}) => + Value livePhotoVideoId = const Value.absent(), + int? visibility, + Value stackId = const Value.absent()}) => RemoteAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -629,7 +657,11 @@ class RemoteAssetEntityData extends DataClass localDateTime.present ? localDateTime.value : this.localDateTime, thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, ); RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { return RemoteAssetEntityData( @@ -652,8 +684,12 @@ class RemoteAssetEntityData extends DataClass : this.localDateTime, thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, visibility: data.visibility.present ? data.visibility.value : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, ); } @@ -674,7 +710,9 @@ class RemoteAssetEntityData extends DataClass ..write('localDateTime: $localDateTime, ') ..write('thumbHash: $thumbHash, ') ..write('deletedAt: $deletedAt, ') - ..write('visibility: $visibility') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') ..write(')')) .toString(); } @@ -695,7 +733,9 @@ class RemoteAssetEntityData extends DataClass localDateTime, thumbHash, deletedAt, - visibility); + livePhotoVideoId, + visibility, + stackId); @override bool operator ==(Object other) => identical(this, other) || @@ -714,7 +754,9 @@ class RemoteAssetEntityData extends DataClass other.localDateTime == this.localDateTime && other.thumbHash == this.thumbHash && other.deletedAt == this.deletedAt && - other.visibility == this.visibility); + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility && + other.stackId == this.stackId); } class RemoteAssetEntityCompanion @@ -733,7 +775,9 @@ class RemoteAssetEntityCompanion final Value localDateTime; final Value thumbHash; final Value deletedAt; + final Value livePhotoVideoId; final Value visibility; + final Value stackId; const RemoteAssetEntityCompanion({ this.name = const Value.absent(), this.type = const Value.absent(), @@ -749,7 +793,9 @@ class RemoteAssetEntityCompanion this.localDateTime = const Value.absent(), this.thumbHash = const Value.absent(), this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), this.visibility = const Value.absent(), + this.stackId = const Value.absent(), }); RemoteAssetEntityCompanion.insert({ required String name, @@ -766,7 +812,9 @@ class RemoteAssetEntityCompanion this.localDateTime = const Value.absent(), this.thumbHash = const Value.absent(), this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), required int visibility, + this.stackId = const Value.absent(), }) : name = Value(name), type = Value(type), id = Value(id), @@ -788,7 +836,9 @@ class RemoteAssetEntityCompanion Expression? localDateTime, Expression? thumbHash, Expression? deletedAt, + Expression? livePhotoVideoId, Expression? visibility, + Expression? stackId, }) { return RawValuesInsertable({ if (name != null) 'name': name, @@ -805,7 +855,9 @@ class RemoteAssetEntityCompanion if (localDateTime != null) 'local_date_time': localDateTime, if (thumbHash != null) 'thumb_hash': thumbHash, if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, }); } @@ -824,7 +876,9 @@ class RemoteAssetEntityCompanion Value? localDateTime, Value? thumbHash, Value? deletedAt, - Value? visibility}) { + Value? livePhotoVideoId, + Value? visibility, + Value? stackId}) { return RemoteAssetEntityCompanion( name: name ?? this.name, type: type ?? this.type, @@ -840,7 +894,9 @@ class RemoteAssetEntityCompanion localDateTime: localDateTime ?? this.localDateTime, thumbHash: thumbHash ?? this.thumbHash, deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, ); } @@ -889,9 +945,15 @@ class RemoteAssetEntityCompanion if (deletedAt.present) { map['deleted_at'] = Variable(deletedAt.value); } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } if (visibility.present) { map['visibility'] = Variable(visibility.value); } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } return map; } @@ -912,7 +974,9 @@ class RemoteAssetEntityCompanion ..write('localDateTime: $localDateTime, ') ..write('thumbHash: $thumbHash, ') ..write('deletedAt: $deletedAt, ') - ..write('visibility: $visibility') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') ..write(')')) .toString(); } @@ -1351,6 +1415,258 @@ class LocalAssetEntityCompanion extends UpdateCompanion { } } +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id)')); + @override + List get $columns => + [id, createdAt, updatedAt, ownerId, primaryAssetId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId}) => + StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId}) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + class UserMetadataEntity extends Table with TableInfo { @override @@ -4329,263 +4645,12 @@ class MemoryAssetEntityCompanion } } -class StackEntity extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - StackEntity(this.attachedDatabase, [this._alias]); - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); - late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); - late final GeneratedColumn ownerId = GeneratedColumn( - 'owner_id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES user_entity (id) ON DELETE CASCADE')); - late final GeneratedColumn primaryAssetId = GeneratedColumn( - 'primary_asset_id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES remote_asset_entity (id)')); - @override - List get $columns => - [id, createdAt, updatedAt, ownerId, primaryAssetId]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'stack_entity'; - @override - Set get $primaryKey => {id}; - @override - StackEntityData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return StackEntityData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, - ownerId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, - primaryAssetId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, - ); - } - - @override - StackEntity createAlias(String alias) { - return StackEntity(attachedDatabase, alias); - } - - @override - bool get withoutRowId => true; - @override - bool get isStrict => true; -} - -class StackEntityData extends DataClass implements Insertable { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final String ownerId; - final String primaryAssetId; - const StackEntityData( - {required this.id, - required this.createdAt, - required this.updatedAt, - required this.ownerId, - required this.primaryAssetId}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['created_at'] = Variable(createdAt); - map['updated_at'] = Variable(updatedAt); - map['owner_id'] = Variable(ownerId); - map['primary_asset_id'] = Variable(primaryAssetId); - return map; - } - - factory StackEntityData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return StackEntityData( - id: serializer.fromJson(json['id']), - createdAt: serializer.fromJson(json['createdAt']), - updatedAt: serializer.fromJson(json['updatedAt']), - ownerId: serializer.fromJson(json['ownerId']), - primaryAssetId: serializer.fromJson(json['primaryAssetId']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'createdAt': serializer.toJson(createdAt), - 'updatedAt': serializer.toJson(updatedAt), - 'ownerId': serializer.toJson(ownerId), - 'primaryAssetId': serializer.toJson(primaryAssetId), - }; - } - - StackEntityData copyWith( - {String? id, - DateTime? createdAt, - DateTime? updatedAt, - String? ownerId, - String? primaryAssetId}) => - StackEntityData( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - primaryAssetId: primaryAssetId ?? this.primaryAssetId, - ); - StackEntityData copyWithCompanion(StackEntityCompanion data) { - return StackEntityData( - id: data.id.present ? data.id.value : this.id, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, - ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, - primaryAssetId: data.primaryAssetId.present - ? data.primaryAssetId.value - : this.primaryAssetId, - ); - } - - @override - String toString() { - return (StringBuffer('StackEntityData(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('updatedAt: $updatedAt, ') - ..write('ownerId: $ownerId, ') - ..write('primaryAssetId: $primaryAssetId') - ..write(')')) - .toString(); - } - - @override - int get hashCode => - Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StackEntityData && - other.id == this.id && - other.createdAt == this.createdAt && - other.updatedAt == this.updatedAt && - other.ownerId == this.ownerId && - other.primaryAssetId == this.primaryAssetId); -} - -class StackEntityCompanion extends UpdateCompanion { - final Value id; - final Value createdAt; - final Value updatedAt; - final Value ownerId; - final Value primaryAssetId; - const StackEntityCompanion({ - this.id = const Value.absent(), - this.createdAt = const Value.absent(), - this.updatedAt = const Value.absent(), - this.ownerId = const Value.absent(), - this.primaryAssetId = const Value.absent(), - }); - StackEntityCompanion.insert({ - required String id, - this.createdAt = const Value.absent(), - this.updatedAt = const Value.absent(), - required String ownerId, - required String primaryAssetId, - }) : id = Value(id), - ownerId = Value(ownerId), - primaryAssetId = Value(primaryAssetId); - static Insertable custom({ - Expression? id, - Expression? createdAt, - Expression? updatedAt, - Expression? ownerId, - Expression? primaryAssetId, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (createdAt != null) 'created_at': createdAt, - if (updatedAt != null) 'updated_at': updatedAt, - if (ownerId != null) 'owner_id': ownerId, - if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, - }); - } - - StackEntityCompanion copyWith( - {Value? id, - Value? createdAt, - Value? updatedAt, - Value? ownerId, - Value? primaryAssetId}) { - return StackEntityCompanion( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - primaryAssetId: primaryAssetId ?? this.primaryAssetId, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (updatedAt.present) { - map['updated_at'] = Variable(updatedAt.value); - } - if (ownerId.present) { - map['owner_id'] = Variable(ownerId.value); - } - if (primaryAssetId.present) { - map['primary_asset_id'] = Variable(primaryAssetId.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('StackEntityCompanion(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('updatedAt: $updatedAt, ') - ..write('ownerId: $ownerId, ') - ..write('primaryAssetId: $primaryAssetId') - ..write(')')) - .toString(); - } -} - class DatabaseAtV1 extends GeneratedDatabase { DatabaseAtV1(QueryExecutor e) : super(e); late final UserEntity userEntity = UserEntity(this); late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); late final Index idxLocalAssetChecksum = Index('idx_local_asset_checksum', 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); late final Index uQRemoteAssetOwnerChecksum = Index( @@ -4606,7 +4671,6 @@ class DatabaseAtV1 extends GeneratedDatabase { RemoteAlbumUserEntity(this); late final MemoryEntity memoryEntity = MemoryEntity(this); late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); - late final StackEntity stackEntity = StackEntity(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -4615,6 +4679,7 @@ class DatabaseAtV1 extends GeneratedDatabase { userEntity, remoteAssetEntity, localAssetEntity, + stackEntity, idxLocalAssetChecksum, uQRemoteAssetOwnerChecksum, idxRemoteAssetChecksum, @@ -4627,8 +4692,7 @@ class DatabaseAtV1 extends GeneratedDatabase { remoteAlbumAssetEntity, remoteAlbumUserEntity, memoryEntity, - memoryAssetEntity, - stackEntity + memoryAssetEntity ]; @override int get schemaVersion => 1; diff --git a/mobile/test/drift/main/generated/schema_v2.dart b/mobile/test/drift/main/generated/schema_v2.dart index bdba8db557..8345cef906 100644 --- a/mobile/test/drift/main/generated/schema_v2.dart +++ b/mobile/test/drift/main/generated/schema_v2.dart @@ -416,6 +416,9 @@ class RemoteAssetEntity extends Table late final GeneratedColumn visibility = GeneratedColumn( 'visibility', aliasedName, false, type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); @override List get $columns => [ name, @@ -433,7 +436,8 @@ class RemoteAssetEntity extends Table thumbHash, deletedAt, livePhotoVideoId, - visibility + visibility, + stackId ]; @override String get aliasedName => _alias ?? actualTableName; @@ -478,6 +482,8 @@ class RemoteAssetEntity extends Table DriftSqlType.string, data['${effectivePrefix}live_photo_video_id']), visibility: attachedDatabase.typeMapping .read(DriftSqlType.int, data['${effectivePrefix}visibility'])!, + stackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}stack_id']), ); } @@ -510,6 +516,7 @@ class RemoteAssetEntityData extends DataClass final DateTime? deletedAt; final String? livePhotoVideoId; final int visibility; + final String? stackId; const RemoteAssetEntityData( {required this.name, required this.type, @@ -526,7 +533,8 @@ class RemoteAssetEntityData extends DataClass this.thumbHash, this.deletedAt, this.livePhotoVideoId, - required this.visibility}); + required this.visibility, + this.stackId}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -560,6 +568,9 @@ class RemoteAssetEntityData extends DataClass map['live_photo_video_id'] = Variable(livePhotoVideoId); } map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } return map; } @@ -583,6 +594,7 @@ class RemoteAssetEntityData extends DataClass deletedAt: serializer.fromJson(json['deletedAt']), livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), ); } @override @@ -605,6 +617,7 @@ class RemoteAssetEntityData extends DataClass 'deletedAt': serializer.toJson(deletedAt), 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), }; } @@ -624,7 +637,8 @@ class RemoteAssetEntityData extends DataClass Value thumbHash = const Value.absent(), Value deletedAt = const Value.absent(), Value livePhotoVideoId = const Value.absent(), - int? visibility}) => + int? visibility, + Value stackId = const Value.absent()}) => RemoteAssetEntityData( name: name ?? this.name, type: type ?? this.type, @@ -647,6 +661,7 @@ class RemoteAssetEntityData extends DataClass ? livePhotoVideoId.value : this.livePhotoVideoId, visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, ); RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { return RemoteAssetEntityData( @@ -674,6 +689,7 @@ class RemoteAssetEntityData extends DataClass : this.livePhotoVideoId, visibility: data.visibility.present ? data.visibility.value : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, ); } @@ -695,7 +711,8 @@ class RemoteAssetEntityData extends DataClass ..write('thumbHash: $thumbHash, ') ..write('deletedAt: $deletedAt, ') ..write('livePhotoVideoId: $livePhotoVideoId, ') - ..write('visibility: $visibility') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') ..write(')')) .toString(); } @@ -717,7 +734,8 @@ class RemoteAssetEntityData extends DataClass thumbHash, deletedAt, livePhotoVideoId, - visibility); + visibility, + stackId); @override bool operator ==(Object other) => identical(this, other) || @@ -737,7 +755,8 @@ class RemoteAssetEntityData extends DataClass other.thumbHash == this.thumbHash && other.deletedAt == this.deletedAt && other.livePhotoVideoId == this.livePhotoVideoId && - other.visibility == this.visibility); + other.visibility == this.visibility && + other.stackId == this.stackId); } class RemoteAssetEntityCompanion @@ -758,6 +777,7 @@ class RemoteAssetEntityCompanion final Value deletedAt; final Value livePhotoVideoId; final Value visibility; + final Value stackId; const RemoteAssetEntityCompanion({ this.name = const Value.absent(), this.type = const Value.absent(), @@ -775,6 +795,7 @@ class RemoteAssetEntityCompanion this.deletedAt = const Value.absent(), this.livePhotoVideoId = const Value.absent(), this.visibility = const Value.absent(), + this.stackId = const Value.absent(), }); RemoteAssetEntityCompanion.insert({ required String name, @@ -793,6 +814,7 @@ class RemoteAssetEntityCompanion this.deletedAt = const Value.absent(), this.livePhotoVideoId = const Value.absent(), required int visibility, + this.stackId = const Value.absent(), }) : name = Value(name), type = Value(type), id = Value(id), @@ -816,6 +838,7 @@ class RemoteAssetEntityCompanion Expression? deletedAt, Expression? livePhotoVideoId, Expression? visibility, + Expression? stackId, }) { return RawValuesInsertable({ if (name != null) 'name': name, @@ -834,6 +857,7 @@ class RemoteAssetEntityCompanion if (deletedAt != null) 'deleted_at': deletedAt, if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, }); } @@ -853,7 +877,8 @@ class RemoteAssetEntityCompanion Value? thumbHash, Value? deletedAt, Value? livePhotoVideoId, - Value? visibility}) { + Value? visibility, + Value? stackId}) { return RemoteAssetEntityCompanion( name: name ?? this.name, type: type ?? this.type, @@ -871,6 +896,7 @@ class RemoteAssetEntityCompanion deletedAt: deletedAt ?? this.deletedAt, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, ); } @@ -925,6 +951,9 @@ class RemoteAssetEntityCompanion if (visibility.present) { map['visibility'] = Variable(visibility.value); } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } return map; } @@ -946,7 +975,8 @@ class RemoteAssetEntityCompanion ..write('thumbHash: $thumbHash, ') ..write('deletedAt: $deletedAt, ') ..write('livePhotoVideoId: $livePhotoVideoId, ') - ..write('visibility: $visibility') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') ..write(')')) .toString(); } @@ -1385,6 +1415,258 @@ class LocalAssetEntityCompanion extends UpdateCompanion { } } +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id)')); + @override + List get $columns => + [id, createdAt, updatedAt, ownerId, primaryAssetId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId}) => + StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId}) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + class UserMetadataEntity extends Table with TableInfo { @override @@ -4363,263 +4645,12 @@ class MemoryAssetEntityCompanion } } -class StackEntity extends Table with TableInfo { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - StackEntity(this.attachedDatabase, [this._alias]); - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); - late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); - late final GeneratedColumn ownerId = GeneratedColumn( - 'owner_id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES user_entity (id) ON DELETE CASCADE')); - late final GeneratedColumn primaryAssetId = GeneratedColumn( - 'primary_asset_id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES remote_asset_entity (id)')); - @override - List get $columns => - [id, createdAt, updatedAt, ownerId, primaryAssetId]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'stack_entity'; - @override - Set get $primaryKey => {id}; - @override - StackEntityData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return StackEntityData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, - ownerId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, - primaryAssetId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, - ); - } - - @override - StackEntity createAlias(String alias) { - return StackEntity(attachedDatabase, alias); - } - - @override - bool get withoutRowId => true; - @override - bool get isStrict => true; -} - -class StackEntityData extends DataClass implements Insertable { - final String id; - final DateTime createdAt; - final DateTime updatedAt; - final String ownerId; - final String primaryAssetId; - const StackEntityData( - {required this.id, - required this.createdAt, - required this.updatedAt, - required this.ownerId, - required this.primaryAssetId}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['created_at'] = Variable(createdAt); - map['updated_at'] = Variable(updatedAt); - map['owner_id'] = Variable(ownerId); - map['primary_asset_id'] = Variable(primaryAssetId); - return map; - } - - factory StackEntityData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return StackEntityData( - id: serializer.fromJson(json['id']), - createdAt: serializer.fromJson(json['createdAt']), - updatedAt: serializer.fromJson(json['updatedAt']), - ownerId: serializer.fromJson(json['ownerId']), - primaryAssetId: serializer.fromJson(json['primaryAssetId']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'createdAt': serializer.toJson(createdAt), - 'updatedAt': serializer.toJson(updatedAt), - 'ownerId': serializer.toJson(ownerId), - 'primaryAssetId': serializer.toJson(primaryAssetId), - }; - } - - StackEntityData copyWith( - {String? id, - DateTime? createdAt, - DateTime? updatedAt, - String? ownerId, - String? primaryAssetId}) => - StackEntityData( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - primaryAssetId: primaryAssetId ?? this.primaryAssetId, - ); - StackEntityData copyWithCompanion(StackEntityCompanion data) { - return StackEntityData( - id: data.id.present ? data.id.value : this.id, - createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, - ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, - primaryAssetId: data.primaryAssetId.present - ? data.primaryAssetId.value - : this.primaryAssetId, - ); - } - - @override - String toString() { - return (StringBuffer('StackEntityData(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('updatedAt: $updatedAt, ') - ..write('ownerId: $ownerId, ') - ..write('primaryAssetId: $primaryAssetId') - ..write(')')) - .toString(); - } - - @override - int get hashCode => - Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StackEntityData && - other.id == this.id && - other.createdAt == this.createdAt && - other.updatedAt == this.updatedAt && - other.ownerId == this.ownerId && - other.primaryAssetId == this.primaryAssetId); -} - -class StackEntityCompanion extends UpdateCompanion { - final Value id; - final Value createdAt; - final Value updatedAt; - final Value ownerId; - final Value primaryAssetId; - const StackEntityCompanion({ - this.id = const Value.absent(), - this.createdAt = const Value.absent(), - this.updatedAt = const Value.absent(), - this.ownerId = const Value.absent(), - this.primaryAssetId = const Value.absent(), - }); - StackEntityCompanion.insert({ - required String id, - this.createdAt = const Value.absent(), - this.updatedAt = const Value.absent(), - required String ownerId, - required String primaryAssetId, - }) : id = Value(id), - ownerId = Value(ownerId), - primaryAssetId = Value(primaryAssetId); - static Insertable custom({ - Expression? id, - Expression? createdAt, - Expression? updatedAt, - Expression? ownerId, - Expression? primaryAssetId, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (createdAt != null) 'created_at': createdAt, - if (updatedAt != null) 'updated_at': updatedAt, - if (ownerId != null) 'owner_id': ownerId, - if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, - }); - } - - StackEntityCompanion copyWith( - {Value? id, - Value? createdAt, - Value? updatedAt, - Value? ownerId, - Value? primaryAssetId}) { - return StackEntityCompanion( - id: id ?? this.id, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ownerId: ownerId ?? this.ownerId, - primaryAssetId: primaryAssetId ?? this.primaryAssetId, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - if (updatedAt.present) { - map['updated_at'] = Variable(updatedAt.value); - } - if (ownerId.present) { - map['owner_id'] = Variable(ownerId.value); - } - if (primaryAssetId.present) { - map['primary_asset_id'] = Variable(primaryAssetId.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('StackEntityCompanion(') - ..write('id: $id, ') - ..write('createdAt: $createdAt, ') - ..write('updatedAt: $updatedAt, ') - ..write('ownerId: $ownerId, ') - ..write('primaryAssetId: $primaryAssetId') - ..write(')')) - .toString(); - } -} - class DatabaseAtV2 extends GeneratedDatabase { DatabaseAtV2(QueryExecutor e) : super(e); late final UserEntity userEntity = UserEntity(this); late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); late final Index idxLocalAssetChecksum = Index('idx_local_asset_checksum', 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); late final Index uQRemoteAssetOwnerChecksum = Index( @@ -4640,7 +4671,6 @@ class DatabaseAtV2 extends GeneratedDatabase { RemoteAlbumUserEntity(this); late final MemoryEntity memoryEntity = MemoryEntity(this); late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); - late final StackEntity stackEntity = StackEntity(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -4649,6 +4679,7 @@ class DatabaseAtV2 extends GeneratedDatabase { userEntity, remoteAssetEntity, localAssetEntity, + stackEntity, idxLocalAssetChecksum, uQRemoteAssetOwnerChecksum, idxRemoteAssetChecksum, @@ -4661,8 +4692,7 @@ class DatabaseAtV2 extends GeneratedDatabase { remoteAlbumAssetEntity, remoteAlbumUserEntity, memoryEntity, - memoryAssetEntity, - stackEntity + memoryAssetEntity ]; @override int get schemaVersion => 2; From 53acf0826318a9b30d1f83ebe3afd1966b13a182 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:08:57 +0200 Subject: [PATCH 20/45] fix(deps): update dependency multer to v2.0.2 [security] (#20003) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/package-lock.json | 67 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 46dfa8fd37..11be49ecb6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -2882,6 +2882,67 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-express/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-express/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@nestjs/platform-socket.io": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.3.tgz", @@ -13595,9 +13656,9 @@ } }, "node_modules/multer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", - "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", From 137f0d48c080145c620fab5b14d8ccd31ec7369b Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Jul 2025 08:36:29 -0500 Subject: [PATCH 21/45] chore: styling for asset_viewer bottom sheet (#20006) bottom sheet styling --- .../pages/drift_recently_taken.page.dart | 6 ++- .../presentation/pages/drift_video.page.dart | 6 ++- .../asset_viewer/bottom_bar.widget.dart | 15 ------- .../asset_viewer/bottom_sheet.widget.dart | 39 +++++++++++-------- .../bottom_sheet/location_details.widget.dart | 13 ++++--- .../base_bottom_sheet.widget.dart | 6 ++- 6 files changed, 43 insertions(+), 42 deletions(-) diff --git a/mobile/lib/presentation/pages/drift_recently_taken.page.dart b/mobile/lib/presentation/pages/drift_recently_taken.page.dart index e2972fad56..df303e7b31 100644 --- a/mobile/lib/presentation/pages/drift_recently_taken.page.dart +++ b/mobile/lib/presentation/pages/drift_recently_taken.page.dart @@ -1,9 +1,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class DriftRecentlyTakenPage extends StatelessWidget { @@ -29,7 +31,9 @@ class DriftRecentlyTakenPage extends StatelessWidget { }, ), ], - child: const Timeline(), + child: Timeline( + appBar: MesmerizingSliverAppBar(title: 'recently_taken'.t()), + ), ); } } diff --git a/mobile/lib/presentation/pages/drift_video.page.dart b/mobile/lib/presentation/pages/drift_video.page.dart index 488d027177..8c0e8e6911 100644 --- a/mobile/lib/presentation/pages/drift_video.page.dart +++ b/mobile/lib/presentation/pages/drift_video.page.dart @@ -1,9 +1,11 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class DriftVideoPage extends StatelessWidget { @@ -27,7 +29,9 @@ class DriftVideoPage extends StatelessWidget { }, ), ], - child: const Timeline(), + child: Timeline( + appBar: MesmerizingSliverAppBar(title: 'videos'.t()), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart index 9237c3bcdb..d35a315f48 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_bar.widget.dart @@ -3,9 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; -import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; @@ -39,7 +37,6 @@ class ViewerBottomBar extends ConsumerWidget { final actions = [ const ShareActionButton(source: ActionSource.viewer), - const _EditActionButton(), if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer), ]; @@ -86,15 +83,3 @@ class ViewerBottomBar extends ConsumerWidget { ); } } - -class _EditActionButton extends ConsumerWidget { - const _EditActionButton(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return BaseActionButton( - iconData: Icons.tune_outlined, - label: 'edit'.t(context: context), - ); - } -} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index 6695022d1e..ea6dece942 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -73,6 +73,7 @@ class AssetDetailBottomSheet extends ConsumerWidget { expand: false, shouldCloseOnMinExtent: false, resizeOnScroll: false, + backgroundColor: context.isDarkTheme ? Colors.black : Colors.white, ); } } @@ -84,14 +85,18 @@ class _AssetDetailBottomSheet extends ConsumerWidget { final dateTime = asset.createdAt.toLocal(); final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); - return '$date$_kSeparator$time'; + final timezone = dateTime.timeZoneOffset.isNegative + ? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}' + : 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'; + return '$date$_kSeparator$time $timezone'; } String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { final height = asset.height ?? exifInfo?.height; final width = asset.width ?? exifInfo?.width; - final resolution = - (width != null && height != null) ? "$width x $height" : null; + final resolution = (width != null && height != null) + ? "${width.toInt()} x ${height.toInt()}" + : null; final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; @@ -150,46 +155,46 @@ class _AssetDetailBottomSheet extends ConsumerWidget { // Asset Date and Time _SheetTile( title: _getDateTime(context, asset), - titleStyle: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - fontSize: 16, + titleStyle: context.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, ), ), const SheetLocationDetails(), // Details header _SheetTile( title: 'exif_bottom_sheet_details'.t(context: context), - titleStyle: context.textTheme.labelLarge, + titleStyle: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), ), // File info _SheetTile( title: asset.name, - titleStyle: context.textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.w600), + titleStyle: context.textTheme.labelLarge, leading: Icon( asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, - size: 30, + size: 24, color: context.textTheme.labelLarge?.color, ), subtitle: _getFileInfo(asset, exifInfo), - subtitleStyle: context.textTheme.labelLarge?.copyWith( - color: context.textTheme.labelLarge?.color?.withAlpha(200), + subtitleStyle: context.textTheme.bodyMedium?.copyWith( + color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), // Camera info if (cameraTitle != null) _SheetTile( title: cameraTitle, - titleStyle: context.textTheme.labelLarge - ?.copyWith(fontWeight: FontWeight.w600), + titleStyle: context.textTheme.labelLarge, leading: Icon( Icons.camera_outlined, - size: 30, + size: 24, color: context.textTheme.labelLarge?.color, ), subtitle: _getCameraInfoSubtitle(exifInfo), - subtitleStyle: context.textTheme.labelLarge?.copyWith( - color: context.textTheme.labelLarge?.color?.withAlpha(200), + subtitleStyle: context.textTheme.bodyMedium?.copyWith( + color: context.textTheme.bodyMedium?.color?.withAlpha(155), ), ), ], diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart index 2bf52bd094..855328ebde 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart @@ -95,7 +95,10 @@ class _SheetLocationDetailsState extends ConsumerState { padding: const EdgeInsets.only(bottom: 16), child: Text( "exif_bottom_sheet_location".t(context: context), - style: context.textTheme.labelLarge, + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(200), + fontWeight: FontWeight.w600, + ), ), ), ExifMap( @@ -109,15 +112,13 @@ class _SheetLocationDetailsState extends ConsumerState { padding: const EdgeInsets.only(bottom: 4.0), child: Text( locationName, - style: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w500, - ), + style: context.textTheme.labelLarge, ), ), Text( coordinates, - style: context.textTheme.labelLarge?.copyWith( - color: context.textTheme.labelLarge?.color?.withAlpha(150), + style: context.textTheme.labelMedium?.copyWith( + color: context.textTheme.labelMedium?.color?.withAlpha(150), ), ), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart index 2db8ae2b4c..e172eec03b 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart @@ -14,6 +14,7 @@ class BaseBottomSheet extends ConsumerStatefulWidget { final bool expand; final bool shouldCloseOnMinExtent; final bool resizeOnScroll; + final Color? backgroundColor; const BaseBottomSheet({ super.key, @@ -26,6 +27,7 @@ class BaseBottomSheet extends ConsumerStatefulWidget { this.expand = true, this.shouldCloseOnMinExtent = true, this.resizeOnScroll = true, + this.backgroundColor, }); @override @@ -69,8 +71,8 @@ class _BaseDraggableScrollableSheetState shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, builder: (BuildContext context, ScrollController scrollController) { return Card( - color: context.colorScheme.surfaceContainerHigh, - surfaceTintColor: context.colorScheme.surfaceContainerHigh, + color: widget.backgroundColor ?? + context.colorScheme.surfaceContainerHigh, borderOnForeground: false, clipBehavior: Clip.antiAlias, elevation: 6.0, From 2e63b9d9518361bc1463c3b75c8a1096a9be3749 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Jul 2025 08:39:28 -0500 Subject: [PATCH 22/45] chore: add sync indicator and better album state management (#20004) * album list rerendering * sync indicator * sync indicator * fix: lint --- mobile/lib/domain/utils/background_sync.dart | 19 ++- mobile/lib/pages/common/tab_shell.page.dart | 53 ++------ .../presentation/pages/drift_album.page.dart | 75 +----------- .../providers/background_sync.provider.dart | 8 +- .../infrastructure/remote_album.provider.dart | 48 ++------ .../lib/providers/sync_status.provider.dart | 68 +++++++++++ .../widgets/common/immich_sliver_app_bar.dart | 113 ++++++++++++++++-- 7 files changed, 226 insertions(+), 158 deletions(-) create mode 100644 mobile/lib/providers/sync_status.provider.dart diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index c71f1a8315..7ab2989354 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -4,13 +4,24 @@ import 'package:immich_mobile/providers/infrastructure/sync.provider.dart'; import 'package:immich_mobile/utils/isolate.dart'; import 'package:worker_manager/worker_manager.dart'; +typedef SyncCallback = void Function(); +typedef SyncErrorCallback = void Function(String error); + class BackgroundSyncManager { + final SyncCallback? onRemoteSyncStart; + final SyncCallback? onRemoteSyncComplete; + final SyncErrorCallback? onRemoteSyncError; + Cancelable? _syncTask; Cancelable? _syncWebsocketTask; Cancelable? _deviceAlbumSyncTask; Cancelable? _hashTask; - BackgroundSyncManager(); + BackgroundSyncManager({ + this.onRemoteSyncStart, + this.onRemoteSyncComplete, + this.onRemoteSyncError, + }); Future cancel() { final futures = []; @@ -72,10 +83,16 @@ class BackgroundSyncManager { return _syncTask!.future; } + onRemoteSyncStart?.call(); + _syncTask = runInIsolateGentle( computation: (ref) => ref.read(syncStreamServiceProvider).sync(), ); return _syncTask!.whenComplete(() { + onRemoteSyncComplete?.call(); + _syncTask = null; + }).catchError((error) { + onRemoteSyncError?.call(error.toString()); _syncTask = null; }); } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 24efff143f..671c1a6156 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -35,42 +35,15 @@ class _TabShellPageState extends ConsumerState { Widget build(BuildContext context) { final isScreenLandscape = context.orientation == Orientation.landscape; - Widget buildIcon({required Widget icon, required bool isProcessing}) { - if (!isProcessing) return icon; - return Stack( - alignment: Alignment.center, - clipBehavior: Clip.none, - children: [ - icon, - Positioned( - right: -18, - child: SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - context.primaryColor, - ), - ), - ), - ), - ], - ); - } - final navigationDestinations = [ NavigationDestination( label: 'photos'.tr(), icon: const Icon( Icons.photo_library_outlined, ), - selectedIcon: buildIcon( - isProcessing: false, - icon: Icon( - Icons.photo_library, - color: context.primaryColor, - ), + selectedIcon: Icon( + Icons.photo_library, + color: context.primaryColor, ), ), NavigationDestination( @@ -88,12 +61,9 @@ class _TabShellPageState extends ConsumerState { icon: const Icon( Icons.photo_album_outlined, ), - selectedIcon: buildIcon( - isProcessing: false, - icon: Icon( - Icons.photo_album_rounded, - color: context.primaryColor, - ), + selectedIcon: Icon( + Icons.photo_album_rounded, + color: context.primaryColor, ), ), NavigationDestination( @@ -101,12 +71,9 @@ class _TabShellPageState extends ConsumerState { icon: const Icon( Icons.space_dashboard_outlined, ), - selectedIcon: buildIcon( - isProcessing: false, - icon: Icon( - Icons.space_dashboard_rounded, - color: context.primaryColor, - ), + selectedIcon: Icon( + Icons.space_dashboard_rounded, + color: context.primaryColor, ), ), ]; @@ -183,7 +150,7 @@ void _onNavigationSelected(TabsRouter router, int index, WidgetRef ref) { // Album page if (index == 2) { - ref.read(remoteAlbumProvider.notifier).getAll(); + ref.read(remoteAlbumProvider.notifier).refresh(); } ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index d59f734c79..fbdf1ef116 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -40,7 +40,7 @@ class _DriftAlbumsPageState extends ConsumerState { // Load albums when component mounts WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(remoteAlbumProvider.notifier).getAll(); + ref.read(remoteAlbumProvider.notifier).refresh(); }); searchController.addListener(() { @@ -88,10 +88,9 @@ class _DriftAlbumsPageState extends ConsumerState { @override Widget build(BuildContext context) { - final albumState = ref.watch(remoteAlbumProvider); - final albums = albumState.filteredAlbums; - final isLoading = albumState.isLoading; - final error = albumState.error; + final albums = + ref.watch(remoteAlbumProvider.select((s) => s.filteredAlbums)); + final userId = ref.watch(currentUserProvider)?.id; return RefreshIndicator( @@ -133,14 +132,10 @@ class _DriftAlbumsPageState extends ConsumerState { ? _AlbumGrid( albums: albums, userId: userId, - isLoading: isLoading, - error: error, ) : _AlbumList( albums: albums, userId: userId, - isLoading: isLoading, - error: error, ), ], ), @@ -481,46 +476,15 @@ class _QuickSortAndViewMode extends StatelessWidget { class _AlbumList extends ConsumerWidget { const _AlbumList({ - required this.isLoading, - required this.error, required this.albums, required this.userId, }); - final bool isLoading; - final String? error; final List albums; final String? userId; @override Widget build(BuildContext context, WidgetRef ref) { - if (isLoading) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ), - ); - } - - if (error != null) { - return SliverToBoxAdapter( - child: Center( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Text( - 'Error loading albums: $error', - style: TextStyle( - color: context.colorScheme.error, - ), - ), - ), - ), - ); - } - if (albums.isEmpty) { return const SliverToBoxAdapter( child: Center( @@ -623,44 +587,13 @@ class _AlbumGrid extends StatelessWidget { const _AlbumGrid({ required this.albums, required this.userId, - required this.isLoading, - required this.error, }); final List albums; final String? userId; - final bool isLoading; - final String? error; @override Widget build(BuildContext context) { - if (isLoading) { - return const SliverToBoxAdapter( - child: Center( - child: Padding( - padding: EdgeInsets.all(20.0), - child: CircularProgressIndicator(), - ), - ), - ); - } - - if (error != null) { - return SliverToBoxAdapter( - child: Center( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Text( - 'Error loading albums: $error', - style: TextStyle( - color: context.colorScheme.error, - ), - ), - ), - ), - ); - } - if (albums.isEmpty) { return const SliverToBoxAdapter( child: Center( diff --git a/mobile/lib/providers/background_sync.provider.dart b/mobile/lib/providers/background_sync.provider.dart index 83d103bb3b..dc9cc0d59f 100644 --- a/mobile/lib/providers/background_sync.provider.dart +++ b/mobile/lib/providers/background_sync.provider.dart @@ -1,8 +1,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/utils/background_sync.dart'; +import 'package:immich_mobile/providers/sync_status.provider.dart'; final backgroundSyncProvider = Provider((ref) { - final manager = BackgroundSyncManager(); + final syncStatusNotifier = ref.read(syncStatusProvider.notifier); + final manager = BackgroundSyncManager( + onRemoteSyncStart: syncStatusNotifier.startRemoteSync, + onRemoteSyncComplete: syncStatusNotifier.completeRemoteSync, + onRemoteSyncError: syncStatusNotifier.errorRemoteSync, + ); ref.onDispose(manager.cancel); return manager; }); diff --git a/mobile/lib/providers/infrastructure/remote_album.provider.dart b/mobile/lib/providers/infrastructure/remote_album.provider.dart index 14badd58ed..2ce10d7cbd 100644 --- a/mobile/lib/providers/infrastructure/remote_album.provider.dart +++ b/mobile/lib/providers/infrastructure/remote_album.provider.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/utils/remote_album.utils.dart'; +import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'album.provider.dart'; @@ -13,33 +14,25 @@ import 'album.provider.dart'; class RemoteAlbumState { final List albums; final List filteredAlbums; - final bool isLoading; - final String? error; const RemoteAlbumState({ required this.albums, List? filteredAlbums, - this.isLoading = false, - this.error, }) : filteredAlbums = filteredAlbums ?? albums; RemoteAlbumState copyWith({ List? albums, List? filteredAlbums, - bool? isLoading, - String? error, }) { return RemoteAlbumState( albums: albums ?? this.albums, filteredAlbums: filteredAlbums ?? this.filteredAlbums, - isLoading: isLoading ?? this.isLoading, - error: error ?? this.error, ); } @override String toString() => - 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length}, isLoading: $isLoading, error: $error)'; + 'RemoteAlbumState(albums: ${albums.length}, filteredAlbums: ${filteredAlbums.length})'; @override bool operator ==(covariant RemoteAlbumState other) { @@ -47,47 +40,38 @@ class RemoteAlbumState { final listEquals = const DeepCollectionEquality().equals; return listEquals(other.albums, albums) && - listEquals(other.filteredAlbums, filteredAlbums) && - other.isLoading == isLoading && - other.error == error; + listEquals(other.filteredAlbums, filteredAlbums); } @override - int get hashCode => - albums.hashCode ^ - filteredAlbums.hashCode ^ - isLoading.hashCode ^ - error.hashCode; + int get hashCode => albums.hashCode ^ filteredAlbums.hashCode; } class RemoteAlbumNotifier extends Notifier { late RemoteAlbumService _remoteAlbumService; - + final _logger = Logger('RemoteAlbumNotifier'); @override RemoteAlbumState build() { _remoteAlbumService = ref.read(remoteAlbumServiceProvider); return const RemoteAlbumState(albums: [], filteredAlbums: []); } - Future> getAll() async { - state = state.copyWith(isLoading: true, error: null); - + Future> _getAll() async { try { final albums = await _remoteAlbumService.getAll(); state = state.copyWith( albums: albums, filteredAlbums: albums, - isLoading: false, ); return albums; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + } catch (error, stack) { + _logger.severe('Failed to fetch albums', error, stack); rethrow; } } Future refresh() async { - await getAll(); + await _getAll(); } void searchAlbums( @@ -127,8 +111,6 @@ class RemoteAlbumNotifier extends Notifier { String? description, List assetIds = const [], }) async { - state = state.copyWith(isLoading: true, error: null); - try { final album = await _remoteAlbumService.createAlbum( title: title, @@ -141,10 +123,9 @@ class RemoteAlbumNotifier extends Notifier { filteredAlbums: [...state.filteredAlbums, album], ); - state = state.copyWith(isLoading: false); return album; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + } catch (error, stack) { + _logger.severe('Failed to create album', error, stack); rethrow; } } @@ -157,8 +138,6 @@ class RemoteAlbumNotifier extends Notifier { bool? isActivityEnabled, AlbumAssetOrder? order, }) async { - state = state.copyWith(isLoading: true, error: null); - try { final updatedAlbum = await _remoteAlbumService.updateAlbum( albumId, @@ -180,12 +159,11 @@ class RemoteAlbumNotifier extends Notifier { state = state.copyWith( albums: updatedAlbums, filteredAlbums: updatedFilteredAlbums, - isLoading: false, ); return updatedAlbum; - } catch (e) { - state = state.copyWith(isLoading: false, error: e.toString()); + } catch (error, stack) { + _logger.severe('Failed to update album', error, stack); rethrow; } } diff --git a/mobile/lib/providers/sync_status.provider.dart b/mobile/lib/providers/sync_status.provider.dart new file mode 100644 index 0000000000..18d851aa19 --- /dev/null +++ b/mobile/lib/providers/sync_status.provider.dart @@ -0,0 +1,68 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +enum SyncStatus { + idle, + syncing, + success, + error, +} + +class SyncStatusState { + final SyncStatus remoteSyncStatus; + final String? errorMessage; + + const SyncStatusState({ + this.remoteSyncStatus = SyncStatus.idle, + this.errorMessage, + }); + + SyncStatusState copyWith({ + SyncStatus? remoteSyncStatus, + String? errorMessage, + }) { + return SyncStatusState( + remoteSyncStatus: remoteSyncStatus ?? this.remoteSyncStatus, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + bool get isRemoteSyncing => remoteSyncStatus == SyncStatus.syncing; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is SyncStatusState && + other.remoteSyncStatus == remoteSyncStatus && + other.errorMessage == errorMessage; + } + + @override + int get hashCode => Object.hash(remoteSyncStatus, errorMessage); +} + +class SyncStatusNotifier extends Notifier { + @override + SyncStatusState build() { + return const SyncStatusState( + errorMessage: null, + remoteSyncStatus: SyncStatus.idle, + ); + } + + void setRemoteSyncStatus(SyncStatus status, [String? errorMessage]) { + state = state.copyWith( + remoteSyncStatus: status, + errorMessage: status == SyncStatus.error ? errorMessage : null, + ); + } + + void startRemoteSync() => setRemoteSyncStatus(SyncStatus.syncing); + void completeRemoteSync() => setRemoteSyncStatus(SyncStatus.success); + void errorRemoteSync(String error) => + setRemoteSyncStatus(SyncStatus.error, error); +} + +final syncStatusProvider = + NotifierProvider( + SyncStatusNotifier.new, +); diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 5ab60c913c..b58a1ad6f9 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/models/server_info/server_info.model.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; +import 'package:immich_mobile/providers/sync_status.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -59,13 +60,6 @@ class ImmichSliverAppBar extends ConsumerWidget { centerTitle: false, title: title ?? const _ImmichLogoWithText(), actions: [ - if (actions != null) - ...actions!.map( - (action) => Padding( - padding: const EdgeInsets.only(right: 16), - child: action, - ), - ), if (isCasting) Padding( padding: const EdgeInsets.only(right: 12), @@ -81,6 +75,14 @@ class ImmichSliverAppBar extends ConsumerWidget { ), ), ), + const _SyncStatusIndicator(), + if (actions != null) + ...actions!.map( + (action) => Padding( + padding: const EdgeInsets.only(right: 16), + child: action, + ), + ), if (showUploadButton) const Padding( padding: EdgeInsets.only(right: 20), @@ -273,3 +275,100 @@ class _BackupIndicator extends ConsumerWidget { return null; } } + +class _SyncStatusIndicator extends ConsumerStatefulWidget { + const _SyncStatusIndicator(); + + @override + ConsumerState<_SyncStatusIndicator> createState() => + _SyncStatusIndicatorState(); +} + +class _SyncStatusIndicatorState extends ConsumerState<_SyncStatusIndicator> + with TickerProviderStateMixin { + late AnimationController _rotationController; + late AnimationController _dismissalController; + late Animation _rotationAnimation; + late Animation _dismissalAnimation; + + @override + void initState() { + super.initState(); + _rotationController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + _dismissalController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _rotationAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(_rotationController); + _dismissalAnimation = Tween( + begin: 1.0, + end: 0.0, + ).animate( + CurvedAnimation( + parent: _dismissalController, + curve: Curves.easeOutQuart, + ), + ); + } + + @override + void dispose() { + _rotationController.dispose(); + _dismissalController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final syncStatus = ref.watch(syncStatusProvider); + final isSyncing = syncStatus.isRemoteSyncing; + + // Control animations based on sync status + if (isSyncing) { + if (!_rotationController.isAnimating) { + _rotationController.repeat(); + } + _dismissalController.reset(); + } else { + _rotationController.stop(); + if (_dismissalController.status == AnimationStatus.dismissed) { + _dismissalController.forward(); + } + } + + // Don't show anything if not syncing and dismissal animation is complete + if (!isSyncing && + _dismissalController.status == AnimationStatus.completed) { + return const SizedBox.shrink(); + } + + return AnimatedBuilder( + animation: Listenable.merge([_rotationAnimation, _dismissalAnimation]), + builder: (context, child) { + return Padding( + padding: EdgeInsets.only(right: isSyncing ? 16 : 0), + child: Transform.scale( + scale: isSyncing ? 1.0 : _dismissalAnimation.value, + child: Opacity( + opacity: isSyncing ? 1.0 : _dismissalAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value * 2 * 3.14159, + child: Icon( + Icons.sync, + size: 24, + color: context.primaryColor, + ), + ), + ), + ), + ); + }, + ); + } +} From 7bae49ebd51e0c4d465f1f9c9d00af02481da205 Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:21:39 +0800 Subject: [PATCH 23/45] feat(mobile): people sync (#19777) * feat(mobile): drift people sync * merge main --------- Co-authored-by: Alex --- .../drift_schemas/main/drift_schema_v1.json | 2 +- .../drift_schemas/main/drift_schema_v2.json | 2 +- mobile/lib/domain/models/memory.model.dart | 16 +- mobile/lib/domain/models/person.model.dart | 125 ++- mobile/lib/domain/models/stack.model.dart | 35 +- .../domain/services/sync_stream.service.dart | 4 + .../entities/person.entity.dart | 34 + .../entities/person.entity.drift.dart | 933 ++++++++++++++++++ .../repositories/db.repository.dart | 2 + .../repositories/db.repository.drift.dart | 23 +- .../repositories/db.repository.steps.dart | 74 ++ .../repositories/person.repository.dart | 36 + .../repositories/sync_api.repository.dart | 3 + .../repositories/sync_stream.repository.dart | 43 + .../models/search/search_filter.model.dart | 4 +- mobile/lib/pages/search/search.page.dart | 2 +- .../pages/dev/feat_in_development.page.dart | 1 + .../pages/dev/media_stat.page.dart | 4 + .../pages/search/drift_search.page.dart | 2 +- .../infrastructure/person.provider.dart | 7 + .../{ => infrastructure}/stack.provider.dart | 0 .../lib/providers/search/people.provider.dart | 2 +- .../providers/search/people.provider.g.dart | 7 +- mobile/lib/repositories/auth.repository.dart | 1 + .../repositories/person_api.repository.dart | 6 +- mobile/lib/services/person.service.dart | 4 +- .../search/search_filter/people_picker.dart | 6 +- .../services/sync_stream_service_test.dart | 4 + .../test/drift/main/generated/schema_v1.dart | 439 +++++++- .../test/drift/main/generated/schema_v2.dart | 439 +++++++- 30 files changed, 2197 insertions(+), 63 deletions(-) create mode 100644 mobile/lib/infrastructure/entities/person.entity.dart create mode 100644 mobile/lib/infrastructure/entities/person.entity.drift.dart create mode 100644 mobile/lib/infrastructure/repositories/person.repository.dart create mode 100644 mobile/lib/providers/infrastructure/person.provider.dart rename mobile/lib/providers/{ => infrastructure}/stack.provider.dart (100%) diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index c19bcfb945..978a9ba8ad 100644 --- a/mobile/drift_schemas/main/drift_schema_v1.json +++ b/mobile/drift_schemas/main/drift_schema_v1.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":17,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumbnail_path","getter_name":"thumbnailPath","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}}]} \ No newline at end of file diff --git a/mobile/drift_schemas/main/drift_schema_v2.json b/mobile/drift_schemas/main/drift_schema_v2.json index c19bcfb945..978a9ba8ad 100644 --- a/mobile/drift_schemas/main/drift_schema_v2.json +++ b/mobile/drift_schemas/main/drift_schema_v2.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0,1],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id)","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id)"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":17,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumbnail_path","getter_name":"thumbnailPath","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}}]} \ No newline at end of file diff --git a/mobile/lib/domain/models/memory.model.dart b/mobile/lib/domain/models/memory.model.dart index ba2a43428f..38d1c7ef7d 100644 --- a/mobile/lib/domain/models/memory.model.dart +++ b/mobile/lib/domain/models/memory.model.dart @@ -124,7 +124,21 @@ class DriftMemory { @override String toString() { - return 'Memory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, ownerId: $ownerId, type: $type, data: $data, isSaved: $isSaved, memoryAt: $memoryAt, seenAt: $seenAt, showAt: $showAt, hideAt: $hideAt, assets: $assets)'; + return '''Memory { + id: $id, + createdAt: $createdAt, + updatedAt: $updatedAt, + deletedAt: ${deletedAt ?? ""}, + ownerId: $ownerId, + type: $type, + data: $data, + isSaved: $isSaved, + memoryAt: $memoryAt, + seenAt: ${seenAt ?? ""}, + showAt: ${showAt ?? ""}, + hideAt: ${hideAt ?? ""}, + assets: $assets +}'''; } @override diff --git a/mobile/lib/domain/models/person.model.dart b/mobile/lib/domain/models/person.model.dart index 10453f768d..d9eee9ae06 100644 --- a/mobile/lib/domain/models/person.model.dart +++ b/mobile/lib/domain/models/person.model.dart @@ -1,7 +1,8 @@ import 'dart:convert'; -class Person { - const Person({ +// TODO: Remove PersonDto once Isar is removed +class PersonDto { + const PersonDto({ required this.id, this.birthDate, required this.isHidden, @@ -22,7 +23,7 @@ class Person { return 'Person(id: $id, birthDate: $birthDate, isHidden: $isHidden, name: $name, thumbnailPath: $thumbnailPath, updatedAt: $updatedAt)'; } - Person copyWith({ + PersonDto copyWith({ String? id, DateTime? birthDate, bool? isHidden, @@ -30,7 +31,7 @@ class Person { String? thumbnailPath, DateTime? updatedAt, }) { - return Person( + return PersonDto( id: id ?? this.id, birthDate: birthDate ?? this.birthDate, isHidden: isHidden ?? this.isHidden, @@ -51,8 +52,8 @@ class Person { }; } - factory Person.fromMap(Map map) { - return Person( + factory PersonDto.fromMap(Map map) { + return PersonDto( id: map['id'] as String, birthDate: map['birthDate'] != null ? DateTime.fromMillisecondsSinceEpoch(map['birthDate'] as int) @@ -68,11 +69,11 @@ class Person { String toJson() => json.encode(toMap()); - factory Person.fromJson(String source) => - Person.fromMap(json.decode(source) as Map); + factory PersonDto.fromJson(String source) => + PersonDto.fromMap(json.decode(source) as Map); @override - bool operator ==(covariant Person other) { + bool operator ==(covariant PersonDto other) { if (identical(this, other)) return true; return other.id == id && @@ -93,3 +94,109 @@ class Person { updatedAt.hashCode; } } + +// Model for a person stored in the server +class Person { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final String thumbnailPath; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + + const Person({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.thumbnailPath, + required this.isFavorite, + required this.isHidden, + required this.color, + this.birthDate, + }); + + Person copyWith({ + String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + String? faceAssetId, + String? thumbnailPath, + bool? isFavorite, + bool? isHidden, + String? color, + DateTime? birthDate, + }) { + return Person( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + String toString() { + return '''Person { + id: $id, + createdAt: $createdAt, + updatedAt: $updatedAt, + ownerId: $ownerId, + name: $name, + faceAssetId: ${faceAssetId ?? ""}, + thumbnailPath: $thumbnailPath, + isFavorite: $isFavorite, + isHidden: $isHidden, + color: ${color ?? ""}, + birthDate: ${birthDate ?? ""} +}'''; + } + + @override + bool operator ==(covariant Person other) { + if (identical(this, other)) return true; + + return other.id == id && + other.createdAt == createdAt && + other.updatedAt == updatedAt && + other.ownerId == ownerId && + other.name == name && + other.faceAssetId == faceAssetId && + other.thumbnailPath == thumbnailPath && + other.isFavorite == isFavorite && + other.isHidden == isHidden && + other.color == color && + other.birthDate == birthDate; + } + + @override + int get hashCode { + return id.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + ownerId.hashCode ^ + name.hashCode ^ + faceAssetId.hashCode ^ + thumbnailPath.hashCode ^ + isFavorite.hashCode ^ + isHidden.hashCode ^ + color.hashCode ^ + birthDate.hashCode; + } +} diff --git a/mobile/lib/domain/models/stack.model.dart b/mobile/lib/domain/models/stack.model.dart index d7faf07a22..6a449a1eb8 100644 --- a/mobile/lib/domain/models/stack.model.dart +++ b/mobile/lib/domain/models/stack.model.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; - // Model for a stack stored in the server class Stack { final String id; @@ -32,34 +30,15 @@ class Stack { ); } - Map toMap() { - return { - 'id': id, - 'createdAt': createdAt.millisecondsSinceEpoch, - 'updatedAt': updatedAt.millisecondsSinceEpoch, - 'ownerId': ownerId, - 'primaryAssetId': primaryAssetId, - }; - } - - factory Stack.fromMap(Map map) { - return Stack( - id: map['id'] as String, - createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int), - updatedAt: DateTime.fromMillisecondsSinceEpoch(map['updatedAt'] as int), - ownerId: map['ownerId'] as String, - primaryAssetId: map['primaryAssetId'] as String, - ); - } - - String toJson() => json.encode(toMap()); - - factory Stack.fromJson(String source) => - Stack.fromMap(json.decode(source) as Map); - @override String toString() { - return 'Stack(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, ownerId: $ownerId, primaryAssetId: $primaryAssetId)'; + return '''Stack { + id: $id, + createdAt: $createdAt, + updatedAt: $updatedAt, + ownerId: $ownerId, + primaryAssetId: $primaryAssetId +}'''; } @override diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 6183865041..9a7d91ced9 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -240,6 +240,10 @@ class SyncStreamService { return _syncStreamRepository.deleteUserMetadatasV1( data.cast(), ); + case SyncEntityType.personV1: + return _syncStreamRepository.updatePeopleV1(data.cast()); + case SyncEntityType.personDeleteV1: + return _syncStreamRepository.deletePeopleV1(data.cast()); default: _logger.warning("Unknown sync data type: $type"); } diff --git a/mobile/lib/infrastructure/entities/person.entity.dart b/mobile/lib/infrastructure/entities/person.entity.dart new file mode 100644 index 0000000000..68dd04cb5f --- /dev/null +++ b/mobile/lib/infrastructure/entities/person.entity.dart @@ -0,0 +1,34 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; +import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; + +class PersonEntity extends Table with DriftDefaultsMixin { + const PersonEntity(); + + TextColumn get id => text()(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + TextColumn get ownerId => + text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); + + TextColumn get name => text()(); + + // TODO: foreign key refering to asset faces + TextColumn get faceAssetId => text().nullable()(); + + TextColumn get thumbnailPath => text()(); + + BoolColumn get isFavorite => boolean()(); + + BoolColumn get isHidden => boolean()(); + + TextColumn get color => text().nullable()(); + + DateTimeColumn get birthDate => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/mobile/lib/infrastructure/entities/person.entity.drift.dart b/mobile/lib/infrastructure/entities/person.entity.drift.dart new file mode 100644 index 0000000000..f0ced63f0e --- /dev/null +++ b/mobile/lib/infrastructure/entities/person.entity.drift.dart @@ -0,0 +1,933 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart' + as i1; +import 'package:immich_mobile/infrastructure/entities/person.entity.dart' as i2; +import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3; +import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart' + as i4; +import 'package:drift/internal/modular.dart' as i5; + +typedef $$PersonEntityTableCreateCompanionBuilder = i1.PersonEntityCompanion + Function({ + required String id, + i0.Value createdAt, + i0.Value updatedAt, + required String ownerId, + required String name, + i0.Value faceAssetId, + required String thumbnailPath, + required bool isFavorite, + required bool isHidden, + i0.Value color, + i0.Value birthDate, +}); +typedef $$PersonEntityTableUpdateCompanionBuilder = i1.PersonEntityCompanion + Function({ + i0.Value id, + i0.Value createdAt, + i0.Value updatedAt, + i0.Value ownerId, + i0.Value name, + i0.Value faceAssetId, + i0.Value thumbnailPath, + i0.Value isFavorite, + i0.Value isHidden, + i0.Value color, + i0.Value birthDate, +}); + +final class $$PersonEntityTableReferences extends i0.BaseReferences< + i0.GeneratedDatabase, i1.$PersonEntityTable, i1.PersonEntityData> { + $$PersonEntityTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) => + i5.ReadDatabaseContainer(db) + .resultSet('user_entity') + .createAlias(i0.$_aliasNameGenerator( + i5.ReadDatabaseContainer(db) + .resultSet('person_entity') + .ownerId, + i5.ReadDatabaseContainer(db) + .resultSet('user_entity') + .id)); + + i4.$$UserEntityTableProcessedTableManager get ownerId { + final $_column = $_itemColumn('owner_id')!; + + final manager = i4 + .$$UserEntityTableTableManager( + $_db, + i5.ReadDatabaseContainer($_db) + .resultSet('user_entity')) + .filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_ownerIdTable($_db)); + if (item == null) return manager; + return i0.ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$PersonEntityTableFilterComposer + extends i0.Composer { + $$PersonEntityTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnFilters get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get name => $composableBuilder( + column: $table.name, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get faceAssetId => $composableBuilder( + column: $table.faceAssetId, + builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get thumbnailPath => $composableBuilder( + column: $table.thumbnailPath, + builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get isFavorite => $composableBuilder( + column: $table.isFavorite, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get isHidden => $composableBuilder( + column: $table.isHidden, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get color => $composableBuilder( + column: $table.color, builder: (column) => i0.ColumnFilters(column)); + + i0.ColumnFilters get birthDate => $composableBuilder( + column: $table.birthDate, builder: (column) => i0.ColumnFilters(column)); + + i4.$$UserEntityTableFilterComposer get ownerId { + final i4.$$UserEntityTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.ownerId, + referencedTable: i5.ReadDatabaseContainer($db) + .resultSet('user_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i4.$$UserEntityTableFilterComposer( + $db: $db, + $table: i5.ReadDatabaseContainer($db) + .resultSet('user_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PersonEntityTableOrderingComposer + extends i0.Composer { + $$PersonEntityTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.ColumnOrderings get id => $composableBuilder( + column: $table.id, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get name => $composableBuilder( + column: $table.name, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get faceAssetId => $composableBuilder( + column: $table.faceAssetId, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get thumbnailPath => $composableBuilder( + column: $table.thumbnailPath, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get isFavorite => $composableBuilder( + column: $table.isFavorite, + builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get isHidden => $composableBuilder( + column: $table.isHidden, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get color => $composableBuilder( + column: $table.color, builder: (column) => i0.ColumnOrderings(column)); + + i0.ColumnOrderings get birthDate => $composableBuilder( + column: $table.birthDate, + builder: (column) => i0.ColumnOrderings(column)); + + i4.$$UserEntityTableOrderingComposer get ownerId { + final i4.$$UserEntityTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.ownerId, + referencedTable: i5.ReadDatabaseContainer($db) + .resultSet('user_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i4.$$UserEntityTableOrderingComposer( + $db: $db, + $table: i5.ReadDatabaseContainer($db) + .resultSet('user_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PersonEntityTableAnnotationComposer + extends i0.Composer { + $$PersonEntityTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + i0.GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + i0.GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + i0.GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + i0.GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + i0.GeneratedColumn get faceAssetId => $composableBuilder( + column: $table.faceAssetId, builder: (column) => column); + + i0.GeneratedColumn get thumbnailPath => $composableBuilder( + column: $table.thumbnailPath, builder: (column) => column); + + i0.GeneratedColumn get isFavorite => $composableBuilder( + column: $table.isFavorite, builder: (column) => column); + + i0.GeneratedColumn get isHidden => + $composableBuilder(column: $table.isHidden, builder: (column) => column); + + i0.GeneratedColumn get color => + $composableBuilder(column: $table.color, builder: (column) => column); + + i0.GeneratedColumn get birthDate => + $composableBuilder(column: $table.birthDate, builder: (column) => column); + + i4.$$UserEntityTableAnnotationComposer get ownerId { + final i4.$$UserEntityTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.ownerId, + referencedTable: i5.ReadDatabaseContainer($db) + .resultSet('user_entity'), + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, + {$addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer}) => + i4.$$UserEntityTableAnnotationComposer( + $db: $db, + $table: i5.ReadDatabaseContainer($db) + .resultSet('user_entity'), + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + )); + return composer; + } +} + +class $$PersonEntityTableTableManager extends i0.RootTableManager< + i0.GeneratedDatabase, + i1.$PersonEntityTable, + i1.PersonEntityData, + i1.$$PersonEntityTableFilterComposer, + i1.$$PersonEntityTableOrderingComposer, + i1.$$PersonEntityTableAnnotationComposer, + $$PersonEntityTableCreateCompanionBuilder, + $$PersonEntityTableUpdateCompanionBuilder, + (i1.PersonEntityData, i1.$$PersonEntityTableReferences), + i1.PersonEntityData, + i0.PrefetchHooks Function({bool ownerId})> { + $$PersonEntityTableTableManager( + i0.GeneratedDatabase db, i1.$PersonEntityTable table) + : super(i0.TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + i1.$$PersonEntityTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + i1.$$PersonEntityTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + i1.$$PersonEntityTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + i0.Value id = const i0.Value.absent(), + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + i0.Value ownerId = const i0.Value.absent(), + i0.Value name = const i0.Value.absent(), + i0.Value faceAssetId = const i0.Value.absent(), + i0.Value thumbnailPath = const i0.Value.absent(), + i0.Value isFavorite = const i0.Value.absent(), + i0.Value isHidden = const i0.Value.absent(), + i0.Value color = const i0.Value.absent(), + i0.Value birthDate = const i0.Value.absent(), + }) => + i1.PersonEntityCompanion( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + ownerId: ownerId, + name: name, + faceAssetId: faceAssetId, + thumbnailPath: thumbnailPath, + isFavorite: isFavorite, + isHidden: isHidden, + color: color, + birthDate: birthDate, + ), + createCompanionCallback: ({ + required String id, + i0.Value createdAt = const i0.Value.absent(), + i0.Value updatedAt = const i0.Value.absent(), + required String ownerId, + required String name, + i0.Value faceAssetId = const i0.Value.absent(), + required String thumbnailPath, + required bool isFavorite, + required bool isHidden, + i0.Value color = const i0.Value.absent(), + i0.Value birthDate = const i0.Value.absent(), + }) => + i1.PersonEntityCompanion.insert( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + ownerId: ownerId, + name: name, + faceAssetId: faceAssetId, + thumbnailPath: thumbnailPath, + isFavorite: isFavorite, + isHidden: isHidden, + color: color, + birthDate: birthDate, + ), + withReferenceMapper: (p0) => p0 + .map((e) => ( + e.readTable(table), + i1.$$PersonEntityTableReferences(db, table, e) + )) + .toList(), + prefetchHooksCallback: ({ownerId = false}) { + return i0.PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends i0.TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (ownerId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.ownerId, + referencedTable: + i1.$$PersonEntityTableReferences._ownerIdTable(db), + referencedColumn: + i1.$$PersonEntityTableReferences._ownerIdTable(db).id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $$PersonEntityTableProcessedTableManager = i0.ProcessedTableManager< + i0.GeneratedDatabase, + i1.$PersonEntityTable, + i1.PersonEntityData, + i1.$$PersonEntityTableFilterComposer, + i1.$$PersonEntityTableOrderingComposer, + i1.$$PersonEntityTableAnnotationComposer, + $$PersonEntityTableCreateCompanionBuilder, + $$PersonEntityTableUpdateCompanionBuilder, + (i1.PersonEntityData, i1.$$PersonEntityTableReferences), + i1.PersonEntityData, + i0.PrefetchHooks Function({bool ownerId})>; + +class $PersonEntityTable extends i2.PersonEntity + with i0.TableInfo<$PersonEntityTable, i1.PersonEntityData> { + @override + final i0.GeneratedDatabase attachedDatabase; + final String? _alias; + $PersonEntityTable(this.attachedDatabase, [this._alias]); + static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id'); + @override + late final i0.GeneratedColumn id = i0.GeneratedColumn( + 'id', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _createdAtMeta = + const i0.VerificationMeta('createdAt'); + @override + late final i0.GeneratedColumn createdAt = + i0.GeneratedColumn('created_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i3.currentDateAndTime); + static const i0.VerificationMeta _updatedAtMeta = + const i0.VerificationMeta('updatedAt'); + @override + late final i0.GeneratedColumn updatedAt = + i0.GeneratedColumn('updated_at', aliasedName, false, + type: i0.DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: i3.currentDateAndTime); + static const i0.VerificationMeta _ownerIdMeta = + const i0.VerificationMeta('ownerId'); + @override + late final i0.GeneratedColumn ownerId = i0.GeneratedColumn( + 'owner_id', aliasedName, false, + type: i0.DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + static const i0.VerificationMeta _nameMeta = + const i0.VerificationMeta('name'); + @override + late final i0.GeneratedColumn name = i0.GeneratedColumn( + 'name', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _faceAssetIdMeta = + const i0.VerificationMeta('faceAssetId'); + @override + late final i0.GeneratedColumn faceAssetId = + i0.GeneratedColumn('face_asset_id', aliasedName, true, + type: i0.DriftSqlType.string, requiredDuringInsert: false); + static const i0.VerificationMeta _thumbnailPathMeta = + const i0.VerificationMeta('thumbnailPath'); + @override + late final i0.GeneratedColumn thumbnailPath = + i0.GeneratedColumn('thumbnail_path', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); + static const i0.VerificationMeta _isFavoriteMeta = + const i0.VerificationMeta('isFavorite'); + @override + late final i0.GeneratedColumn isFavorite = i0.GeneratedColumn( + 'is_favorite', aliasedName, false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))')); + static const i0.VerificationMeta _isHiddenMeta = + const i0.VerificationMeta('isHidden'); + @override + late final i0.GeneratedColumn isHidden = i0.GeneratedColumn( + 'is_hidden', aliasedName, false, + type: i0.DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: i0.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_hidden" IN (0, 1))')); + static const i0.VerificationMeta _colorMeta = + const i0.VerificationMeta('color'); + @override + late final i0.GeneratedColumn color = i0.GeneratedColumn( + 'color', aliasedName, true, + type: i0.DriftSqlType.string, requiredDuringInsert: false); + static const i0.VerificationMeta _birthDateMeta = + const i0.VerificationMeta('birthDate'); + @override + late final i0.GeneratedColumn birthDate = + i0.GeneratedColumn('birth_date', aliasedName, true, + type: i0.DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + thumbnailPath, + isFavorite, + isHidden, + color, + birthDate + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + i0.VerificationContext validateIntegrity( + i0.Insertable instance, + {bool isInserting = false}) { + final context = i0.VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + } + if (data.containsKey('owner_id')) { + context.handle(_ownerIdMeta, + ownerId.isAcceptableOrUnknown(data['owner_id']!, _ownerIdMeta)); + } else if (isInserting) { + context.missing(_ownerIdMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('face_asset_id')) { + context.handle( + _faceAssetIdMeta, + faceAssetId.isAcceptableOrUnknown( + data['face_asset_id']!, _faceAssetIdMeta)); + } + if (data.containsKey('thumbnail_path')) { + context.handle( + _thumbnailPathMeta, + thumbnailPath.isAcceptableOrUnknown( + data['thumbnail_path']!, _thumbnailPathMeta)); + } else if (isInserting) { + context.missing(_thumbnailPathMeta); + } + if (data.containsKey('is_favorite')) { + context.handle( + _isFavoriteMeta, + isFavorite.isAcceptableOrUnknown( + data['is_favorite']!, _isFavoriteMeta)); + } else if (isInserting) { + context.missing(_isFavoriteMeta); + } + if (data.containsKey('is_hidden')) { + context.handle(_isHiddenMeta, + isHidden.isAcceptableOrUnknown(data['is_hidden']!, _isHiddenMeta)); + } else if (isInserting) { + context.missing(_isHiddenMeta); + } + if (data.containsKey('color')) { + context.handle( + _colorMeta, color.isAcceptableOrUnknown(data['color']!, _colorMeta)); + } + if (data.containsKey('birth_date')) { + context.handle(_birthDateMeta, + birthDate.isAcceptableOrUnknown(data['birth_date']!, _birthDateMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + i1.PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return i1.PersonEntityData( + id: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read( + i0.DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + name: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}name'])!, + faceAssetId: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, data['${effectivePrefix}face_asset_id']), + thumbnailPath: attachedDatabase.typeMapping.read( + i0.DriftSqlType.string, data['${effectivePrefix}thumbnail_path'])!, + isFavorite: attachedDatabase.typeMapping + .read(i0.DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + isHidden: attachedDatabase.typeMapping + .read(i0.DriftSqlType.bool, data['${effectivePrefix}is_hidden'])!, + color: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}color']), + birthDate: attachedDatabase.typeMapping + .read(i0.DriftSqlType.dateTime, data['${effectivePrefix}birth_date']), + ); + } + + @override + $PersonEntityTable createAlias(String alias) { + return $PersonEntityTable(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends i0.DataClass + implements i0.Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final String thumbnailPath; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.thumbnailPath, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = i0.Variable(id); + map['created_at'] = i0.Variable(createdAt); + map['updated_at'] = i0.Variable(updatedAt); + map['owner_id'] = i0.Variable(ownerId); + map['name'] = i0.Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = i0.Variable(faceAssetId); + } + map['thumbnail_path'] = i0.Variable(thumbnailPath); + map['is_favorite'] = i0.Variable(isFavorite); + map['is_hidden'] = i0.Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = i0.Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = i0.Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson(Map json, + {i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + thumbnailPath: serializer.fromJson(json['thumbnailPath']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({i0.ValueSerializer? serializer}) { + serializer ??= i0.driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'thumbnailPath': serializer.toJson(thumbnailPath), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + i1.PersonEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + i0.Value faceAssetId = const i0.Value.absent(), + String? thumbnailPath, + bool? isFavorite, + bool? isHidden, + i0.Value color = const i0.Value.absent(), + i0.Value birthDate = const i0.Value.absent()}) => + i1.PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(i1.PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: + data.faceAssetId.present ? data.faceAssetId.value : this.faceAssetId, + thumbnailPath: data.thumbnailPath.present + ? data.thumbnailPath.value + : this.thumbnailPath, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, ownerId, name, + faceAssetId, thumbnailPath, isFavorite, isHidden, color, birthDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is i1.PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.thumbnailPath == this.thumbnailPath && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends i0.UpdateCompanion { + final i0.Value id; + final i0.Value createdAt; + final i0.Value updatedAt; + final i0.Value ownerId; + final i0.Value name; + final i0.Value faceAssetId; + final i0.Value thumbnailPath; + final i0.Value isFavorite; + final i0.Value isHidden; + final i0.Value color; + final i0.Value birthDate; + const PersonEntityCompanion({ + this.id = const i0.Value.absent(), + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + this.ownerId = const i0.Value.absent(), + this.name = const i0.Value.absent(), + this.faceAssetId = const i0.Value.absent(), + this.thumbnailPath = const i0.Value.absent(), + this.isFavorite = const i0.Value.absent(), + this.isHidden = const i0.Value.absent(), + this.color = const i0.Value.absent(), + this.birthDate = const i0.Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const i0.Value.absent(), + this.updatedAt = const i0.Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const i0.Value.absent(), + required String thumbnailPath, + required bool isFavorite, + required bool isHidden, + this.color = const i0.Value.absent(), + this.birthDate = const i0.Value.absent(), + }) : id = i0.Value(id), + ownerId = i0.Value(ownerId), + name = i0.Value(name), + thumbnailPath = i0.Value(thumbnailPath), + isFavorite = i0.Value(isFavorite), + isHidden = i0.Value(isHidden); + static i0.Insertable custom({ + i0.Expression? id, + i0.Expression? createdAt, + i0.Expression? updatedAt, + i0.Expression? ownerId, + i0.Expression? name, + i0.Expression? faceAssetId, + i0.Expression? thumbnailPath, + i0.Expression? isFavorite, + i0.Expression? isHidden, + i0.Expression? color, + i0.Expression? birthDate, + }) { + return i0.RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (thumbnailPath != null) 'thumbnail_path': thumbnailPath, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + i1.PersonEntityCompanion copyWith( + {i0.Value? id, + i0.Value? createdAt, + i0.Value? updatedAt, + i0.Value? ownerId, + i0.Value? name, + i0.Value? faceAssetId, + i0.Value? thumbnailPath, + i0.Value? isFavorite, + i0.Value? isHidden, + i0.Value? color, + i0.Value? birthDate}) { + return i1.PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = i0.Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = i0.Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = i0.Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = i0.Variable(ownerId.value); + } + if (name.present) { + map['name'] = i0.Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = i0.Variable(faceAssetId.value); + } + if (thumbnailPath.present) { + map['thumbnail_path'] = i0.Variable(thumbnailPath.value); + } + if (isFavorite.present) { + map['is_favorite'] = i0.Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = i0.Variable(isHidden.value); + } + if (color.present) { + map['color'] = i0.Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = i0.Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 200ec47516..0a763c91cc 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/person.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.dart'; @@ -54,6 +55,7 @@ class IsarDatabaseRepository implements IDatabaseRepository { MemoryEntity, MemoryAssetEntity, StackEntity, + PersonEntity, ], include: { 'package:immich_mobile/infrastructure/entities/merged_asset.drift', diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index 3b826c209b..0f822e57eb 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -29,9 +29,11 @@ import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart' as i13; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart' as i14; -import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' +import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart' as i15; -import 'package:drift/internal/modular.dart' as i16; +import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' + as i16; +import 'package:drift/internal/modular.dart' as i17; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); @@ -61,8 +63,9 @@ abstract class $Drift extends i0.GeneratedDatabase { late final i13.$MemoryEntityTable memoryEntity = i13.$MemoryEntityTable(this); late final i14.$MemoryAssetEntityTable memoryAssetEntity = i14.$MemoryAssetEntityTable(this); - i15.MergedAssetDrift get mergedAssetDrift => i16.ReadDatabaseContainer(this) - .accessor(i15.MergedAssetDrift.new); + late final i15.$PersonEntityTable personEntity = i15.$PersonEntityTable(this); + i16.MergedAssetDrift get mergedAssetDrift => i17.ReadDatabaseContainer(this) + .accessor(i16.MergedAssetDrift.new); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -84,7 +87,8 @@ abstract class $Drift extends i0.GeneratedDatabase { remoteAlbumAssetEntity, remoteAlbumUserEntity, memoryEntity, - memoryAssetEntity + memoryAssetEntity, + personEntity ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => @@ -216,6 +220,13 @@ abstract class $Drift extends i0.GeneratedDatabase { i0.TableUpdate('memory_asset_entity', kind: i0.UpdateKind.delete), ], ), + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('user_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('person_entity', kind: i0.UpdateKind.delete), + ], + ), ], ); @override @@ -255,4 +266,6 @@ class $DriftManager { i13.$$MemoryEntityTableTableManager(_db, _db.memoryEntity); i14.$$MemoryAssetEntityTableTableManager get memoryAssetEntity => i14.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity); + i15.$$PersonEntityTableTableManager get personEntity => + i15.$$PersonEntityTableTableManager(_db, _db.personEntity); } diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 4b27ce830c..76e5df76d0 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -26,6 +26,7 @@ final class Schema2 extends i0.VersionedSchema { remoteAlbumUserEntity, memoryEntity, memoryAssetEntity, + personEntity, ]; late final Shape0 userEntity = Shape0( source: i0.VersionedTable( @@ -321,6 +322,30 @@ final class Schema2 extends i0.VersionedSchema { attachedDatabase: database, ), alias: null); + late final Shape13 personEntity = Shape13( + source: i0.VersionedTable( + entityName: 'person_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_1, + _column_69, + _column_70, + _column_71, + _column_72, + _column_73, + _column_74, + ], + attachedDatabase: database, + ), + alias: null); } class Shape0 extends i0.VersionedTable { @@ -845,6 +870,55 @@ i1.GeneratedColumn _column_68(String aliasedName) => type: i1.DriftSqlType.string, defaultConstraints: i1.GeneratedColumn.constraintIsAlways( 'REFERENCES memory_entity (id) ON DELETE CASCADE')); + +class Shape13 extends i0.VersionedTable { + Shape13({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get id => + columnsByName['id']! as i1.GeneratedColumn; + i1.GeneratedColumn get createdAt => + columnsByName['created_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get updatedAt => + columnsByName['updated_at']! as i1.GeneratedColumn; + i1.GeneratedColumn get ownerId => + columnsByName['owner_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get name => + columnsByName['name']! as i1.GeneratedColumn; + i1.GeneratedColumn get faceAssetId => + columnsByName['face_asset_id']! as i1.GeneratedColumn; + i1.GeneratedColumn get thumbnailPath => + columnsByName['thumbnail_path']! as i1.GeneratedColumn; + i1.GeneratedColumn get isFavorite => + columnsByName['is_favorite']! as i1.GeneratedColumn; + i1.GeneratedColumn get isHidden => + columnsByName['is_hidden']! as i1.GeneratedColumn; + i1.GeneratedColumn get color => + columnsByName['color']! as i1.GeneratedColumn; + i1.GeneratedColumn get birthDate => + columnsByName['birth_date']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_69(String aliasedName) => + i1.GeneratedColumn('face_asset_id', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_70(String aliasedName) => + i1.GeneratedColumn('thumbnail_path', aliasedName, false, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_71(String aliasedName) => + i1.GeneratedColumn('is_favorite', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))')); +i1.GeneratedColumn _column_72(String aliasedName) => + i1.GeneratedColumn('is_hidden', aliasedName, false, + type: i1.DriftSqlType.bool, + defaultConstraints: i1.GeneratedColumn.constraintIsAlways( + 'CHECK ("is_hidden" IN (0, 1))')); +i1.GeneratedColumn _column_73(String aliasedName) => + i1.GeneratedColumn('color', aliasedName, true, + type: i1.DriftSqlType.string); +i1.GeneratedColumn _column_74(String aliasedName) => + i1.GeneratedColumn('birth_date', aliasedName, true, + type: i1.DriftSqlType.dateTime); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, }) { diff --git a/mobile/lib/infrastructure/repositories/person.repository.dart b/mobile/lib/infrastructure/repositories/person.repository.dart new file mode 100644 index 0000000000..859765d63b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/person.repository.dart @@ -0,0 +1,36 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/person.model.dart'; +import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftPersonRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftPersonRepository(this._db) : super(_db); + + Future> getAll(String userId) { + final query = _db.personEntity.select() + ..where((e) => e.ownerId.equals(userId)); + + return query.map((person) { + return person.toDto(); + }).get(); + } +} + +extension on PersonEntityData { + Person toDto() { + return Person( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + ownerId: ownerId, + name: name, + faceAssetId: faceAssetId, + thumbnailPath: thumbnailPath, + isFavorite: isFavorite, + isHidden: isHidden, + color: color, + birthDate: birthDate, + ); + } +} diff --git a/mobile/lib/infrastructure/repositories/sync_api.repository.dart b/mobile/lib/infrastructure/repositories/sync_api.repository.dart index d1ecbd580c..11d58663e0 100644 --- a/mobile/lib/infrastructure/repositories/sync_api.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_api.repository.dart @@ -57,6 +57,7 @@ class SyncApiRepository { SyncRequestType.stacksV1, SyncRequestType.partnerStacksV1, SyncRequestType.userMetadataV1, + SyncRequestType.peopleV1, ], ).toJson(), ); @@ -173,6 +174,8 @@ const _kResponseMap = { SyncEntityType.partnerStackDeleteV1: SyncStackDeleteV1.fromJson, SyncEntityType.userMetadataV1: SyncUserMetadataV1.fromJson, SyncEntityType.userMetadataDeleteV1: SyncUserMetadataDeleteV1.fromJson, + SyncEntityType.personV1: SyncPersonV1.fromJson, + SyncEntityType.personDeleteV1: SyncPersonDeleteV1.fromJson, }; class _SyncAckV1 { diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index d28c68ed78..fb5c7fdb3c 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'; @@ -511,6 +512,48 @@ class SyncStreamRepository extends DriftDatabaseRepository { rethrow; } } + + Future updatePeopleV1(Iterable data) async { + try { + await _db.batch((batch) { + for (final person in data) { + final companion = PersonEntityCompanion( + createdAt: Value(person.createdAt), + updatedAt: Value(person.updatedAt), + ownerId: Value(person.ownerId), + name: Value(person.name), + faceAssetId: Value(person.faceAssetId), + thumbnailPath: Value(person.thumbnailPath), + isFavorite: Value(person.isFavorite), + isHidden: Value(person.isHidden), + color: Value(person.color), + birthDate: Value(person.birthDate), + ); + + batch.insert( + _db.personEntity, + companion.copyWith(id: Value(person.id)), + onConflict: DoUpdate((_) => companion), + ); + } + }); + } catch (error, stack) { + _logger.severe('Error: updatePeopleV1', error, stack); + rethrow; + } + } + + Future deletePeopleV1( + Iterable data, + ) async { + try { + await _db.personEntity.deleteWhere( + (row) => row.id.isIn(data.map((e) => e.personId)), + ); + } catch (error, stack) { + _logger.severe('Error: deletePeopleV1', error, stack); + } + } } extension on AssetTypeEnum { diff --git a/mobile/lib/models/search/search_filter.model.dart b/mobile/lib/models/search/search_filter.model.dart index 835e6aff8f..efe6f923ad 100644 --- a/mobile/lib/models/search/search_filter.model.dart +++ b/mobile/lib/models/search/search_filter.model.dart @@ -237,7 +237,7 @@ class SearchFilter { String? filename; String? description; String? language; - Set people; + Set people; SearchLocationFilter location; SearchCameraFilter camera; SearchDateFilter date; @@ -282,7 +282,7 @@ class SearchFilter { String? filename, String? description, String? language, - Set? people, + Set? people, SearchLocationFilter? location, SearchCameraFilter? camera, SearchDateFilter? date, diff --git a/mobile/lib/pages/search/search.page.dart b/mobile/lib/pages/search/search.page.dart index dba3199fac..3e5c153f88 100644 --- a/mobile/lib/pages/search/search.page.dart +++ b/mobile/lib/pages/search/search.page.dart @@ -147,7 +147,7 @@ class SearchPage extends HookConsumerWidget { ); showPeoplePicker() { - handleOnSelect(Set value) { + handleOnSelect(Set value) { filter.value = filter.value.copyWith( people: value, ); diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index 2815b2db83..e94329acd2 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -112,6 +112,7 @@ final _features = [ await db.memoryEntity.deleteAll(); await db.memoryAssetEntity.deleteAll(); await db.stackEntity.deleteAll(); + await db.personEntity.deleteAll(); }, ), _Feature( diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index e61dcdf90d..acd7b219b3 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -166,6 +166,10 @@ final _remoteStats = [ name: 'Stacks', load: (db) => db.managers.stackEntity.count(), ), + _Stat( + name: 'People', + load: (db) => db.managers.personEntity.count(), + ), ]; @RoutePage() diff --git a/mobile/lib/presentation/pages/search/drift_search.page.dart b/mobile/lib/presentation/pages/search/drift_search.page.dart index 5a5eb44b3b..7101a42b01 100644 --- a/mobile/lib/presentation/pages/search/drift_search.page.dart +++ b/mobile/lib/presentation/pages/search/drift_search.page.dart @@ -151,7 +151,7 @@ class DriftSearchPage extends HookConsumerWidget { ); showPeoplePicker() { - handleOnSelect(Set value) { + handleOnSelect(Set value) { filter.value = filter.value.copyWith( people: value, ); diff --git a/mobile/lib/providers/infrastructure/person.provider.dart b/mobile/lib/providers/infrastructure/person.provider.dart new file mode 100644 index 0000000000..a733104b33 --- /dev/null +++ b/mobile/lib/providers/infrastructure/person.provider.dart @@ -0,0 +1,7 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/repositories/person.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final driftPersonProvider = Provider( + (ref) => DriftPersonRepository(ref.watch(driftProvider)), +); diff --git a/mobile/lib/providers/stack.provider.dart b/mobile/lib/providers/infrastructure/stack.provider.dart similarity index 100% rename from mobile/lib/providers/stack.provider.dart rename to mobile/lib/providers/infrastructure/stack.provider.dart diff --git a/mobile/lib/providers/search/people.provider.dart b/mobile/lib/providers/search/people.provider.dart index d03d533aaf..f6ac9d1125 100644 --- a/mobile/lib/providers/search/people.provider.dart +++ b/mobile/lib/providers/search/people.provider.dart @@ -9,7 +9,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'people.provider.g.dart'; @riverpod -Future> getAllPeople( +Future> getAllPeople( Ref ref, ) async { final PersonService personService = ref.read(personServiceProvider); diff --git a/mobile/lib/providers/search/people.provider.g.dart b/mobile/lib/providers/search/people.provider.g.dart index 391edd362c..4625891abb 100644 --- a/mobile/lib/providers/search/people.provider.g.dart +++ b/mobile/lib/providers/search/people.provider.g.dart @@ -6,11 +6,12 @@ part of 'people.provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$getAllPeopleHash() => r'226947af3b09ce62224916543958dd1d5e2ba651'; +String _$getAllPeopleHash() => r'2c5e6a207683f15ab209650615fdf9cb7f76c736'; /// See also [getAllPeople]. @ProviderFor(getAllPeople) -final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( +final getAllPeopleProvider = + AutoDisposeFutureProvider>.internal( getAllPeople, name: r'getAllPeopleProvider', debugGetCreateSourceHash: @@ -21,7 +22,7 @@ final getAllPeopleProvider = AutoDisposeFutureProvider>.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; +typedef GetAllPeopleRef = AutoDisposeFutureProviderRef>; String _$personAssetsHash() => r'c1d35ee0e024bd6915e21bc724be4b458a14bc24'; /// Copied from Dart SDK diff --git a/mobile/lib/repositories/auth.repository.dart b/mobile/lib/repositories/auth.repository.dart index eb86e1809f..5cf357d5a4 100644 --- a/mobile/lib/repositories/auth.repository.dart +++ b/mobile/lib/repositories/auth.repository.dart @@ -34,6 +34,7 @@ class AuthRepository extends DatabaseRepository { _drift.userMetadataEntity.deleteAll(), _drift.partnerEntity.deleteAll(), _drift.stackEntity.deleteAll(), + _drift.personEntity.deleteAll(), ]); // Drift deletions - parent entities await Future.wait([ diff --git a/mobile/lib/repositories/person_api.repository.dart b/mobile/lib/repositories/person_api.repository.dart index a2a6e2489b..26f11dd51d 100644 --- a/mobile/lib/repositories/person_api.repository.dart +++ b/mobile/lib/repositories/person_api.repository.dart @@ -13,19 +13,19 @@ class PersonApiRepository extends ApiRepository { PersonApiRepository(this._api); - Future> getAll() async { + Future> getAll() async { final dto = await checkNull(_api.getAllPeople()); return dto.people.map(_toPerson).toList(); } - Future update(String id, {String? name}) async { + Future update(String id, {String? name}) async { final dto = await checkNull( _api.updatePerson(id, PersonUpdateDto(name: name)), ); return _toPerson(dto); } - static Person _toPerson(PersonResponseDto dto) => Person( + static PersonDto _toPerson(PersonResponseDto dto) => PersonDto( birthDate: dto.birthDate, id: dto.id, isHidden: dto.isHidden, diff --git a/mobile/lib/services/person.service.dart b/mobile/lib/services/person.service.dart index a591ad4f27..08b18dfd10 100644 --- a/mobile/lib/services/person.service.dart +++ b/mobile/lib/services/person.service.dart @@ -28,7 +28,7 @@ class PersonService { this._assetRepository, ); - Future> getAllPeople() async { + Future> getAllPeople() async { try { return await _personApiRepository.getAll(); } catch (error, stack) { @@ -48,7 +48,7 @@ class PersonService { return []; } - Future updateName(String id, String name) async { + Future updateName(String id, String name) async { try { return await _personApiRepository.update(id, name: name); } catch (error, stack) { diff --git a/mobile/lib/widgets/search/search_filter/people_picker.dart b/mobile/lib/widgets/search/search_filter/people_picker.dart index 44d01d274e..05f699b44b 100644 --- a/mobile/lib/widgets/search/search_filter/people_picker.dart +++ b/mobile/lib/widgets/search/search_filter/people_picker.dart @@ -14,8 +14,8 @@ import 'package:immich_mobile/widgets/common/search_field.dart'; class PeoplePicker extends HookConsumerWidget { const PeoplePicker({super.key, required this.onSelect, this.filter}); - final Function(Set) onSelect; - final Set? filter; + final Function(Set) onSelect; + final Set? filter; @override Widget build(BuildContext context, WidgetRef ref) { @@ -24,7 +24,7 @@ class PeoplePicker extends HookConsumerWidget { final searchQuery = useState(''); final people = ref.watch(getAllPeopleProvider); final headers = ApiService.getRequestHeaders(); - final selectedPeople = useState>(filter ?? {}); + final selectedPeople = useState>(filter ?? {}); return Column( children: [ diff --git a/mobile/test/domain/services/sync_stream_service_test.dart b/mobile/test/domain/services/sync_stream_service_test.dart index c9fd8104e4..deb19dfcf8 100644 --- a/mobile/test/domain/services/sync_stream_service_test.dart +++ b/mobile/test/domain/services/sync_stream_service_test.dart @@ -105,6 +105,10 @@ void main() { .thenAnswer(successHandler); when(() => mockSyncStreamRepo.deleteUserMetadatasV1(any())) .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.updatePeopleV1(any())) + .thenAnswer(successHandler); + when(() => mockSyncStreamRepo.deletePeopleV1(any())) + .thenAnswer(successHandler); sut = SyncStreamService( syncApiRepository: mockSyncApiRepo, diff --git a/mobile/test/drift/main/generated/schema_v1.dart b/mobile/test/drift/main/generated/schema_v1.dart index 38dbb9f827..d7b88ea3cf 100644 --- a/mobile/test/drift/main/generated/schema_v1.dart +++ b/mobile/test/drift/main/generated/schema_v1.dart @@ -4645,6 +4645,441 @@ class MemoryAssetEntityCompanion } } +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn thumbnailPath = GeneratedColumn( + 'thumbnail_path', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))')); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_hidden" IN (0, 1))')); + late final GeneratedColumn color = GeneratedColumn( + 'color', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + thumbnailPath, + isFavorite, + isHidden, + color, + birthDate + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + faceAssetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}face_asset_id']), + thumbnailPath: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumbnail_path'])!, + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + isHidden: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_hidden'])!, + color: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}color']), + birthDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}birth_date']), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final String thumbnailPath; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.thumbnailPath, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['thumbnail_path'] = Variable(thumbnailPath); + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + thumbnailPath: serializer.fromJson(json['thumbnailPath']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'thumbnailPath': serializer.toJson(thumbnailPath), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + String? thumbnailPath, + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent()}) => + PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: + data.faceAssetId.present ? data.faceAssetId.value : this.faceAssetId, + thumbnailPath: data.thumbnailPath.present + ? data.thumbnailPath.value + : this.thumbnailPath, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, ownerId, name, + faceAssetId, thumbnailPath, isFavorite, isHidden, color, birthDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.thumbnailPath == this.thumbnailPath && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value thumbnailPath; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.thumbnailPath = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required String thumbnailPath, + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + thumbnailPath = Value(thumbnailPath), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? thumbnailPath, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (thumbnailPath != null) 'thumbnail_path': thumbnailPath, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? thumbnailPath, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate}) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (thumbnailPath.present) { + map['thumbnail_path'] = Variable(thumbnailPath.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + class DatabaseAtV1 extends GeneratedDatabase { DatabaseAtV1(QueryExecutor e) : super(e); late final UserEntity userEntity = UserEntity(this); @@ -4671,6 +5106,7 @@ class DatabaseAtV1 extends GeneratedDatabase { RemoteAlbumUserEntity(this); late final MemoryEntity memoryEntity = MemoryEntity(this); late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -4692,7 +5128,8 @@ class DatabaseAtV1 extends GeneratedDatabase { remoteAlbumAssetEntity, remoteAlbumUserEntity, memoryEntity, - memoryAssetEntity + memoryAssetEntity, + personEntity ]; @override int get schemaVersion => 1; diff --git a/mobile/test/drift/main/generated/schema_v2.dart b/mobile/test/drift/main/generated/schema_v2.dart index 8345cef906..e3edac3501 100644 --- a/mobile/test/drift/main/generated/schema_v2.dart +++ b/mobile/test/drift/main/generated/schema_v2.dart @@ -4645,6 +4645,441 @@ class MemoryAssetEntityCompanion } } +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn thumbnailPath = GeneratedColumn( + 'thumbnail_path', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))')); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_hidden" IN (0, 1))')); + late final GeneratedColumn color = GeneratedColumn( + 'color', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + thumbnailPath, + isFavorite, + isHidden, + color, + birthDate + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + faceAssetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}face_asset_id']), + thumbnailPath: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumbnail_path'])!, + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + isHidden: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_hidden'])!, + color: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}color']), + birthDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}birth_date']), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final String thumbnailPath; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.thumbnailPath, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['thumbnail_path'] = Variable(thumbnailPath); + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + thumbnailPath: serializer.fromJson(json['thumbnailPath']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'thumbnailPath': serializer.toJson(thumbnailPath), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + String? thumbnailPath, + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent()}) => + PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: + data.faceAssetId.present ? data.faceAssetId.value : this.faceAssetId, + thumbnailPath: data.thumbnailPath.present + ? data.thumbnailPath.value + : this.thumbnailPath, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, ownerId, name, + faceAssetId, thumbnailPath, isFavorite, isHidden, color, birthDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.thumbnailPath == this.thumbnailPath && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value thumbnailPath; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.thumbnailPath = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required String thumbnailPath, + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + thumbnailPath = Value(thumbnailPath), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? thumbnailPath, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (thumbnailPath != null) 'thumbnail_path': thumbnailPath, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? thumbnailPath, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate}) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (thumbnailPath.present) { + map['thumbnail_path'] = Variable(thumbnailPath.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + class DatabaseAtV2 extends GeneratedDatabase { DatabaseAtV2(QueryExecutor e) : super(e); late final UserEntity userEntity = UserEntity(this); @@ -4671,6 +5106,7 @@ class DatabaseAtV2 extends GeneratedDatabase { RemoteAlbumUserEntity(this); late final MemoryEntity memoryEntity = MemoryEntity(this); late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -4692,7 +5128,8 @@ class DatabaseAtV2 extends GeneratedDatabase { remoteAlbumAssetEntity, remoteAlbumUserEntity, memoryEntity, - memoryAssetEntity + memoryAssetEntity, + personEntity ]; @override int get schemaVersion => 2; From f32d4f15b63112309f6b89bec73ba367f7c26f61 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Fri, 18 Jul 2025 09:37:07 -0500 Subject: [PATCH 24/45] feat(mobile): android widgets (#19310) * wip * wip widgets * more wip changes * latest changes * working random widget * cleanup * add configurable widget * add memory widget and cleanup of codebase * album name handling * add deeplinks * finish minor refactoring and add some polish :) * fix single shot type on random widget Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> * switch to ExposedDropdownMenuBox for random configure activity * handle empty album and no connection edge cases * android project cleanup * fix proguard and gson issues * fix deletion handling * fix proguard stripping for widget model classes/enums * change random configuration activity close to a checkmark on right side --------- Co-authored-by: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> --- mobile/android/app/build.gradle | 19 ++ mobile/android/app/proguard-rules.pro | 9 +- .../android/app/src/main/AndroidManifest.xml | 38 ++- .../app/alextran/immich/widget/BitmapUtils.kt | 33 +++ .../immich/widget/ImageDownloadWorker.kt | 241 ++++++++++++++++++ .../app/alextran/immich/widget/ImmichAPI.kt | 103 ++++++++ .../alextran/immich/widget/MemoryReceiver.kt | 56 ++++ .../app/alextran/immich/widget/PhotoWidget.kt | 124 +++++++++ .../alextran/immich/widget/RandomReceiver.kt | 55 ++++ .../immich/widget/configure/Dropdown.kt | 64 +++++ .../immich/widget/configure/LightDarkTheme.kt | 28 ++ .../widget/configure/RandomConfigure.kt | 210 +++++++++++++++ .../app/alextran/immich/widget/model/Model.kt | 79 ++++++ .../res/drawable-nodpi/memory_preview.png | Bin 0 -> 246192 bytes .../res/drawable-nodpi/random_preview.png | Bin 0 -> 250040 bytes .../app/src/main/res/values/strings.xml | 8 + .../app/src/main/res/xml/memory_widget.xml | 9 + .../app/src/main/res/xml/random_widget.xml | 13 + mobile/lib/constants/constants.dart | 7 +- .../lib/repositories/widget.repository.dart | 7 +- mobile/lib/services/widget.service.dart | 7 +- 21 files changed, 1098 insertions(+), 12 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt create mode 100644 mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png create mode 100644 mobile/android/app/src/main/res/drawable-nodpi/random_preview.png create mode 100644 mobile/android/app/src/main/res/values/strings.xml create mode 100644 mobile/android/app/src/main/res/xml/memory_widget.xml create mode 100644 mobile/android/app/src/main/res/xml/random_widget.xml diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 870e424461..8b4dc42b7e 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -3,6 +3,8 @@ plugins { id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" id 'com.google.devtools.ksp' + id 'org.jetbrains.kotlin.plugin.compose' version '2.0.20' // this version matches your Kotlin version + } def localProperties = new Properties() @@ -45,6 +47,10 @@ android { main.java.srcDirs += 'src/main/kotlin' } + buildFeatures { + compose true + } + defaultConfig { applicationId "app.alextran.immich" minSdkVersion 26 @@ -105,6 +111,8 @@ dependencies { def guava_version = '33.3.1-android' def glide_version = '4.16.0' def serialization_version = '1.8.1' + def compose_version = '1.1.1' + def gson_version = '2.10.1' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" @@ -116,6 +124,17 @@ dependencies { ksp "com.github.bumptech.glide:ksp:$glide_version" coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.2' + + //Glance Widget + implementation "androidx.glance:glance-appwidget:$compose_version" + implementation "com.google.code.gson:gson:$gson_version" + + // Glance Configure + implementation "androidx.activity:activity-compose:1.8.2" + implementation "androidx.compose.ui:ui:$compose_version" + implementation "androidx.compose.ui:ui-tooling:$compose_version" + implementation "androidx.compose.material3:material3:1.2.1" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" } // This is uncommented in F-Droid build script diff --git a/mobile/android/app/proguard-rules.pro b/mobile/android/app/proguard-rules.pro index ea6dd795b5..898caee06c 100644 --- a/mobile/android/app/proguard-rules.pro +++ b/mobile/android/app/proguard-rules.pro @@ -25,8 +25,15 @@ @com.google.gson.annotations.SerializedName ; } +# TypeToken preventions +-keep class com.google.gson.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken + # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. -keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken -keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken -##---------------End: proguard configuration for Gson ---------- \ No newline at end of file +##---------------End: proguard configuration for Gson ---------- + +# Keep all widget model classes and their fields for Gson +-keep class app.alextran.immich.widget.model.** { *; } \ No newline at end of file diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index cf3b7ee719..3aa72d2876 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -141,6 +141,42 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" /> + + + + + + + + + + + + + + + + + + + > + + + + + @@ -154,4 +190,4 @@ - \ No newline at end of file + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt new file mode 100644 index 0000000000..9188df1700 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/BitmapUtils.kt @@ -0,0 +1,33 @@ +package app.alextran.immich.widget + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File + +fun loadScaledBitmap(file: File, reqWidth: Int, reqHeight: Int): Bitmap? { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, options) + + options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight) + options.inJustDecodeBounds = false + + return BitmapFactory.decodeFile(file.absolutePath, options) +} + +fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { + val (height: Int, width: Int) = options.run { outHeight to outWidth } + var inSampleSize = 1 + + if (height > reqHeight || width > reqWidth) { + val halfHeight: Int = height / 2 + val halfWidth: Int = width / 2 + + while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { + inSampleSize *= 2 + } + } + + return inSampleSize +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt new file mode 100644 index 0000000000..2a23d0ecbd --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt @@ -0,0 +1,241 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.datastore.preferences.core.Preferences +import androidx.glance.* +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.util.UUID +import java.util.concurrent.TimeUnit +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import app.alextran.immich.widget.model.* +import java.time.LocalDate + +class ImageDownloadWorker( + private val context: Context, + workerParameters: WorkerParameters +) : CoroutineWorker(context, workerParameters) { + + companion object { + + private val uniqueWorkName = ImageDownloadWorker::class.java.simpleName + + private fun buildConstraints(): Constraints { + return Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + } + + private fun buildInputData(appWidgetId: Int, widgetType: WidgetType): Data { + return Data.Builder() + .putString(kWorkerWidgetType, widgetType.toString()) + .putInt(kWorkerWidgetID, appWidgetId) + .build() + } + + fun enqueuePeriodic(context: Context, appWidgetId: Int, widgetType: WidgetType) { + val manager = WorkManager.getInstance(context) + + val workRequest = PeriodicWorkRequestBuilder( + 20, TimeUnit.MINUTES + ) + .setConstraints(buildConstraints()) + .setInputData(buildInputData(appWidgetId, widgetType)) + .addTag(appWidgetId.toString()) + .build() + + manager.enqueueUniquePeriodicWork( + "$uniqueWorkName-$appWidgetId", + ExistingPeriodicWorkPolicy.UPDATE, + workRequest + ) + } + + fun singleShot(context: Context, appWidgetId: Int, widgetType: WidgetType) { + val manager = WorkManager.getInstance(context) + + val workRequest = OneTimeWorkRequestBuilder() + .setConstraints(buildConstraints()) + .setInputData(buildInputData(appWidgetId, widgetType)) + .addTag(appWidgetId.toString()) + .build() + + manager.enqueueUniqueWork( + "$uniqueWorkName-$appWidgetId", + ExistingWorkPolicy.REPLACE, + workRequest + ) + } + + suspend fun cancel(context: Context, appWidgetId: Int) { + WorkManager.getInstance(context).cancelAllWorkByTag("$uniqueWorkName-$appWidgetId") + + // delete cached image + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val currentImgUUID = widgetConfig[kImageUUID] + + if (!currentImgUUID.isNullOrEmpty()) { + val file = File(context.cacheDir, imageFilename(currentImgUUID)) + file.delete() + } + } + } + + override suspend fun doWork(): Result { + return try { + val widgetType = WidgetType.valueOf(inputData.getString(kWorkerWidgetType) ?: "") + val widgetId = inputData.getInt(kWorkerWidgetID, -1) + val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(widgetId) + val widgetConfig = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val currentImgUUID = widgetConfig[kImageUUID] + + val serverConfig = ImmichAPI.getServerConfig(context) + + // clear any image caches and go to "login" state if no credentials + if (serverConfig == null) { + if (!currentImgUUID.isNullOrEmpty()) { + deleteImage(currentImgUUID) + updateWidget( + glanceId, + "", + "", + "immich://", + WidgetState.LOG_IN + ) + } + + return Result.success() + } + + // fetch new image + val entry = when (widgetType) { + WidgetType.RANDOM -> fetchRandom(serverConfig, widgetConfig) + WidgetType.MEMORIES -> fetchMemory(serverConfig) + } + + // clear current image if it exists + if (!currentImgUUID.isNullOrEmpty()) { + deleteImage(currentImgUUID) + } + + // save a new image + val imgUUID = UUID.randomUUID().toString() + saveImage(entry.image, imgUUID) + + // trigger the update routine with new image uuid + updateWidget(glanceId, imgUUID, entry.subtitle, entry.deeplink) + + Result.success() + } catch (e: Exception) { + Log.e(uniqueWorkName, "Error while loading image", e) + if (runAttemptCount < 10) { + Result.retry() + } else { + Result.failure() + } + } + } + + private suspend fun updateWidget( + glanceId: GlanceId, + imageUUID: String, + subtitle: String?, + deeplink: String?, + widgetState: WidgetState = WidgetState.SUCCESS + ) { + updateAppWidgetState(context, glanceId) { prefs -> + prefs[kNow] = System.currentTimeMillis() + prefs[kImageUUID] = imageUUID + prefs[kWidgetState] = widgetState.toString() + prefs[kSubtitleText] = subtitle ?: "" + prefs[kDeeplinkURL] = deeplink ?: "" + } + + PhotoWidget().update(context,glanceId) + } + + private suspend fun fetchRandom( + serverConfig: ServerConfig, + widgetConfig: Preferences + ): WidgetEntry { + val api = ImmichAPI(serverConfig) + + val filters = SearchFilters(AssetType.IMAGE) + val albumId = widgetConfig[kSelectedAlbum] + val showSubtitle = widgetConfig[kShowAlbumName] + val albumName = widgetConfig[kSelectedAlbumName] + var subtitle: String? = if (showSubtitle == true) albumName else "" + + if (albumId != null) { + filters.albumIds = listOf(albumId) + } + + var randomSearch = api.fetchSearchResults(filters) + + // handle an empty album, fallback to random + if (randomSearch.isEmpty() && albumId != null) { + randomSearch = api.fetchSearchResults(SearchFilters(AssetType.IMAGE)) + subtitle = "" + } + + val random = randomSearch.first() + val image = api.fetchImage(random) + + return WidgetEntry( + image, + subtitle, + assetDeeplink(random) + ) + } + + private suspend fun fetchMemory( + serverConfig: ServerConfig + ): WidgetEntry { + val api = ImmichAPI(serverConfig) + + val today = LocalDate.now() + val memories = api.fetchMemory(today) + val asset: Asset + var subtitle: String? = null + + if (memories.isNotEmpty()) { + // pick a random asset from a random memory + val memory = memories.random() + asset = memory.assets.random() + + val yearDiff = today.year - memory.data.year + subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago" + } else { + val filters = SearchFilters(AssetType.IMAGE, size=1) + asset = api.fetchSearchResults(filters).first() + } + + val image = api.fetchImage(asset) + return WidgetEntry( + image, + subtitle, + assetDeeplink(asset) + ) + } + + private suspend fun deleteImage(uuid: String) = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, imageFilename(uuid)) + file.delete() + } + + private suspend fun saveImage(bitmap: Bitmap, uuid: String) = withContext(Dispatchers.IO) { + val file = File(context.cacheDir, imageFilename(uuid)) + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt new file mode 100644 index 0000000000..42f5fb4b1b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImmichAPI.kt @@ -0,0 +1,103 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import app.alextran.immich.widget.model.* +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import es.antonborri.home_widget.HomeWidgetPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class ImmichAPI(cfg: ServerConfig) { + + companion object { + fun getServerConfig(context: Context): ServerConfig? { + val prefs = HomeWidgetPlugin.getData(context) + + val serverURL = prefs.getString("widget_server_url", "") ?: "" + val sessionKey = prefs.getString("widget_auth_token", "") ?: "" + + if (serverURL.isBlank() || sessionKey.isBlank()) { + return null + } + + return ServerConfig( + serverURL, + sessionKey + ) + } + } + + + private val gson = Gson() + private val serverConfig = cfg + + private fun buildRequestURL(endpoint: String, params: List> = emptyList()): URL { + val urlString = StringBuilder("${serverConfig.serverEndpoint}$endpoint?sessionKey=${serverConfig.sessionKey}") + + for ((key, value) in params) { + urlString.append("&${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}") + } + + return URL(urlString.toString()) + } + + suspend fun fetchSearchResults(filters: SearchFilters): List = withContext(Dispatchers.IO) { + val url = buildRequestURL("/search/random") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + setRequestProperty("Content-Type", "application/json") + doOutput = true + } + + connection.outputStream.use { + OutputStreamWriter(it).use { writer -> + writer.write(gson.toJson(filters)) + writer.flush() + } + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } + + suspend fun fetchMemory(date: LocalDate): List = withContext(Dispatchers.IO) { + val iso8601 = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + val url = buildRequestURL("/memories", listOf("for" to iso8601)) + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } + + suspend fun fetchImage(asset: Asset): Bitmap = withContext(Dispatchers.IO) { + val url = buildRequestURL("/assets/${asset.id}/thumbnail", listOf("size" to "preview")) + val connection = url.openConnection() + val data = connection.getInputStream().readBytes() + BitmapFactory.decodeByteArray(data, 0, data.size) + ?: throw Exception("Invalid image data") + } + + suspend fun fetchAlbums(): List = withContext(Dispatchers.IO) { + val url = buildRequestURL("/albums") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + } + + val response = connection.inputStream.bufferedReader().readText() + val type = object : TypeToken>() {}.type + gson.fromJson(response, type) + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt new file mode 100644 index 0000000000..7721af7d6f --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/MemoryReceiver.kt @@ -0,0 +1,56 @@ +package app.alextran.immich.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import app.alextran.immich.widget.model.* +import es.antonborri.home_widget.HomeWidgetPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MemoryReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = PhotoWidget() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + appWidgetIds.forEach { widgetID -> + ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.MEMORIES) + } + } + + override fun onReceive(context: Context, intent: Intent) { + val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) + + // Launch coroutine to setup a single shot if the app requested the update + if (fromMainApp) { + CoroutineScope(Dispatchers.Default).launch { + val provider = ComponentName(context, MemoryReceiver::class.java) + val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) + + glanceIds.forEach { widgetID -> + ImageDownloadWorker.singleShot(context, widgetID, WidgetType.MEMORIES) + } + } + } + + super.onReceive(context, intent) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + CoroutineScope(Dispatchers.Default).launch { + appWidgetIds.forEach { id -> + ImageDownloadWorker.cancel(context, id) + } + } + } +} + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt new file mode 100644 index 0000000000..b1a0a9de31 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/PhotoWidget.kt @@ -0,0 +1,124 @@ +package app.alextran.immich.widget + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.* +import androidx.core.net.toUri +import androidx.datastore.preferences.core.MutablePreferences +import androidx.glance.appwidget.* +import androidx.glance.* +import androidx.glance.action.clickable +import androidx.glance.layout.* +import androidx.glance.state.GlanceStateDefinition +import androidx.glance.state.PreferencesGlanceStateDefinition +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import app.alextran.immich.R +import app.alextran.immich.widget.model.* +import java.io.File + +class PhotoWidget : GlanceAppWidget() { + override var stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition + + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + val prefs = currentState() + + val imageUUID = prefs[kImageUUID] + val subtitle = prefs[kSubtitleText] + val deeplinkURL = prefs[kDeeplinkURL]?.toUri() + val widgetState = prefs[kWidgetState] + var bitmap: Bitmap? = null + + if (imageUUID != null) { + // fetch a random photo from server + val file = File(context.cacheDir, imageFilename(imageUUID)) + + if (file.exists()) { + bitmap = loadScaledBitmap(file, 500, 500) + } + } + + // WIDGET CONTENT + Box( + modifier = GlanceModifier + .fillMaxSize() + .background(GlanceTheme.colors.background) + .clickable { + val intent = Intent(Intent.ACTION_VIEW, deeplinkURL ?: "immich://".toUri()) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + } + ) { + if (bitmap != null) { + Image( + provider = ImageProvider(bitmap), + contentDescription = "Widget Image", + contentScale = ContentScale.Crop, + modifier = GlanceModifier.fillMaxSize() + ) + + if (!subtitle.isNullOrBlank()) { + Column( + verticalAlignment = Alignment.Bottom, + horizontalAlignment = Alignment.Start, + modifier = GlanceModifier + .fillMaxSize() + .padding(12.dp) + ) { + Text( + text = subtitle, + style = TextStyle( + color = ColorProvider(Color.White), + fontSize = 16.sp + ), + modifier = GlanceModifier + .background(ColorProvider(Color(0x99000000))) // 60% black + .padding(8.dp) + .cornerRadius(8.dp) + ) + } + } + } else { + Column( + modifier = GlanceModifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + provider = ImageProvider(R.drawable.splash), + contentDescription = null, + ) + + if (widgetState == WidgetState.LOG_IN.toString()) { + Box( + modifier = GlanceModifier.fillMaxWidth().padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text("Log in to your Immich server", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary)) + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = GlanceModifier.fillMaxWidth().padding(16.dp) + ) { + CircularProgressIndicator( + modifier = GlanceModifier.size(12.dp) + ) + + Spacer(modifier = GlanceModifier.width(8.dp)) + + Text("Loading widget...", style = TextStyle(textAlign = TextAlign.Center, color = GlanceTheme.colors.primary)) + } + } + } + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt new file mode 100644 index 0000000000..39afd76c35 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/RandomReceiver.kt @@ -0,0 +1,55 @@ +package app.alextran.immich.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import es.antonborri.home_widget.HomeWidgetPlugin +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import app.alextran.immich.widget.model.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class RandomReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget = PhotoWidget() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + appWidgetIds.forEach { widgetID -> + ImageDownloadWorker.enqueuePeriodic(context, widgetID, WidgetType.RANDOM) + } + } + + override fun onReceive(context: Context, intent: Intent) { + val fromMainApp = intent.getBooleanExtra(HomeWidgetPlugin.TRIGGERED_FROM_HOME_WIDGET, false) + + // Launch coroutine to setup a single shot if the app requested the update + if (fromMainApp) { + CoroutineScope(Dispatchers.Default).launch { + val provider = ComponentName(context, RandomReceiver::class.java) + val glanceIds = AppWidgetManager.getInstance(context).getAppWidgetIds(provider) + + glanceIds.forEach { widgetID -> + ImageDownloadWorker.singleShot(context, widgetID, WidgetType.RANDOM) + } + } + } + + super.onReceive(context, intent) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + CoroutineScope(Dispatchers.Default).launch { + appWidgetIds.forEach { id -> + ImageDownloadWorker.cancel(context, id) + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt new file mode 100644 index 0000000000..74686ee0b8 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/Dropdown.kt @@ -0,0 +1,64 @@ +package app.alextran.immich.widget.configure + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* + + +data class DropdownItem ( + val label: String, + val id: String, +) + +// Creating a composable to display a drop down menu +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Dropdown(items: List, + selectedItem: DropdownItem?, + onItemSelected: (DropdownItem) -> Unit, + enabled: Boolean = true +) { + + var expanded by remember { mutableStateOf(false) } + var selectedOption by remember { mutableStateOf(selectedItem?.label ?: items[0].label) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded && enabled }, + ) { + + TextField( + value = selectedOption, + onValueChange = {}, + readOnly = true, + enabled = enabled, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier + .fillMaxWidth() + .menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + items.forEach { option -> + DropdownMenuItem( + text = { Text(option.label, color = MaterialTheme.colorScheme.onSurface) }, + onClick = { + selectedOption = option.label + onItemSelected(option) + + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding + ) + } + } + } + } + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt new file mode 100644 index 0000000000..efdcc41540 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/LightDarkTheme.kt @@ -0,0 +1,28 @@ +package app.alextran.immich.widget.configure + +import android.os.Build +import androidx.compose.foundation.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun LightDarkTheme( + content: @Composable () -> Unit +) { + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + + val colorScheme = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isDarkTheme -> + dynamicDarkColorScheme(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !isDarkTheme -> + dynamicLightColorScheme(context) + isDarkTheme -> darkColorScheme() + else -> lightColorScheme() + } + MaterialTheme( + colorScheme = colorScheme, + content = content + ) +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt new file mode 100644 index 0000000000..d0490c023e --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt @@ -0,0 +1,210 @@ +package app.alextran.immich.widget.configure + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.state.getAppWidgetState +import androidx.glance.appwidget.state.updateAppWidgetState +import androidx.glance.state.PreferencesGlanceStateDefinition +import app.alextran.immich.widget.ImageDownloadWorker +import app.alextran.immich.widget.ImmichAPI +import app.alextran.immich.widget.model.* +import kotlinx.coroutines.launch +import java.io.FileNotFoundException + +class RandomConfigure : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Get widget ID from intent + val appWidgetId = intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID) + ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + val glanceId = GlanceAppWidgetManager(applicationContext) + .getGlanceIdBy(appWidgetId) + + setContent { + LightDarkTheme { + RandomConfiguration(applicationContext, appWidgetId, glanceId, onDone = { + finish() + Log.w("WIDGET_ACTIVITY", "SAVING") + }) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, onDone: () -> Unit) { + + var selectedAlbum by remember { mutableStateOf(null) } + var showAlbumName by remember { mutableStateOf(false) } + var availableAlbums by remember { mutableStateOf>(listOf()) } + var state by remember { mutableStateOf(WidgetConfigState.LOADING) } + + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + // get albums from server + val serverCfg = ImmichAPI.getServerConfig(context) + + if (serverCfg == null) { + state = WidgetConfigState.LOG_IN + return@LaunchedEffect + } + + val api = ImmichAPI(serverCfg) + + val currentState = getAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) + val currentAlbumId = currentState[kSelectedAlbum] ?: "NONE" + val currentAlbumName = currentState[kSelectedAlbumName] ?: "None" + var albumItems: List + + try { + albumItems = api.fetchAlbums().map { + DropdownItem(it.albumName, it.id) + } + + state = WidgetConfigState.SUCCESS + } catch (e: FileNotFoundException) { + Log.e("WidgetWorker", "Error fetching albums: ${e.message}") + + state = WidgetConfigState.NO_CONNECTION + albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId)) + } + + availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems + + // load selected configuration + val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId } + selectedAlbum = albumEntity ?: availableAlbums.first() + + // load showAlbumName + showAlbumName = currentState[kShowAlbumName] == true + } + + suspend fun saveConfiguration() { + updateAppWidgetState(context, glanceId) { prefs -> + prefs[kSelectedAlbum] = selectedAlbum?.id ?: "" + prefs[kSelectedAlbumName] = selectedAlbum?.label ?: "" + prefs[kShowAlbumName] = showAlbumName + } + + ImageDownloadWorker.singleShot(context, appWidgetId, WidgetType.RANDOM) + } + + Scaffold( + topBar = { + TopAppBar ( + title = { Text("Widget Configuration") }, + actions = { + IconButton(onClick = { + scope.launch { + saveConfiguration() + onDone() + } + }) { + Icon(Icons.Default.Check, contentDescription = "Close", tint = MaterialTheme.colorScheme.primary) + } + } + ) + } + ) { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), // Respect the top bar + color = MaterialTheme.colorScheme.background + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + when (state) { + WidgetConfigState.LOADING -> CircularProgressIndicator(modifier = Modifier.size(48.dp)) + WidgetConfigState.LOG_IN -> Text("You must log in inside the Immich App to configure this widget.") + else -> { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("View a random image from your library or a specific album.", style = MaterialTheme.typography.bodyMedium) + + // no connection warning + if (state == WidgetConfigState.NO_CONNECTION) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = "Warning", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = "No connection to the server is available. Please try again later.", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text("Album") + Dropdown( + items = availableAlbums, + selectedItem = selectedAlbum, + onItemSelected = { selectedAlbum = it }, + enabled = (state != WidgetConfigState.NO_CONNECTION) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "Show Album Name") + Switch( + checked = showAlbumName, + onCheckedChange = { showAlbumName = it }, + enabled = (state != WidgetConfigState.NO_CONNECTION) + ) + } + } + } + } + } + } + } + } +} + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt new file mode 100644 index 0000000000..2337de0612 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt @@ -0,0 +1,79 @@ +package app.alextran.immich.widget.model + +import android.graphics.Bitmap +import androidx.datastore.preferences.core.* + +// MARK: Immich Entities + +enum class AssetType { + IMAGE, VIDEO, AUDIO, OTHER +} + +data class Asset( + val id: String, + val type: AssetType, +) + +data class SearchFilters( + var type: AssetType = AssetType.IMAGE, + val size: Int = 1, + var albumIds: List = listOf() +) + +data class MemoryResult( + val id: String, + var assets: List, + val type: String, + val data: MemoryData +) { + data class MemoryData(val year: Int) +} + +data class Album( + val id: String, + val albumName: String +) + +// MARK: Widget Specific + +enum class WidgetType { + RANDOM, MEMORIES; +} + +enum class WidgetState { + LOADING, SUCCESS, LOG_IN; +} + +enum class WidgetConfigState { + LOADING, SUCCESS, LOG_IN, NO_CONNECTION +} + +data class WidgetEntry ( + val image: Bitmap, + val subtitle: String?, + val deeplink: String? +) + +data class ServerConfig(val serverEndpoint: String, val sessionKey: String) + +// MARK: Widget State Keys +val kImageUUID = stringPreferencesKey("uuid") +val kSubtitleText = stringPreferencesKey("subtitle") +val kNow = longPreferencesKey("now") +val kWidgetState = stringPreferencesKey("state") +val kSelectedAlbum = stringPreferencesKey("albumID") +val kSelectedAlbumName = stringPreferencesKey("albumName") +val kShowAlbumName = booleanPreferencesKey("showAlbumName") +val kDeeplinkURL = stringPreferencesKey("deeplink") + +const val kWorkerWidgetType = "widgetType" +const val kWorkerWidgetID = "widgetId" +const val kTriggeredFromApp = "triggeredFromApp" + +fun imageFilename(id: String): String { + return "widget_image_$id.jpg" +} + +fun assetDeeplink(asset: Asset): String { + return "immich://asset?id=${asset.id}" +} diff --git a/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png b/mobile/android/app/src/main/res/drawable-nodpi/memory_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..97aceb3ef6a1f91071af9c4f444f0f77254bf255 GIT binary patch literal 246192 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 z֯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

1BQ}t6PiNT8@GtCPq!?H%sm#N~P)qnHPx-Kls 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�sH+P##kT_@3CnrC?zor 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�x*9JHH`C2_v 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 DQTQ90 literal 0 HcmV?d00001 diff --git a/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png b/mobile/android/app/src/main/res/drawable-nodpi/random_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..f94d1bbcd55126c21c2744745171898561d037af GIT binary patch literal 250040 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ⅆJh%lN#KuzO{b@n}ex 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 pEbinsdr@Ef_WPgrX8RKu{~u}<@R + + Memories + Random + + See memories from Immich. + View a random image from your library or a specific album. + diff --git a/mobile/android/app/src/main/res/xml/memory_widget.xml b/mobile/android/app/src/main/res/xml/memory_widget.xml new file mode 100644 index 0000000000..611c5aae02 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/memory_widget.xml @@ -0,0 +1,9 @@ + diff --git a/mobile/android/app/src/main/res/xml/random_widget.xml b/mobile/android/app/src/main/res/xml/random_widget.xml new file mode 100644 index 0000000000..25fb24754f --- /dev/null +++ b/mobile/android/app/src/main/res/xml/random_widget.xml @@ -0,0 +1,13 @@ + diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 6d98152efc..c37498ea3e 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -28,7 +28,8 @@ const String appShareGroupId = "group.app.immich.share"; // add widget identifiers here for new widgets // these are used to force a widget refresh -const List kWidgetNames = [ - 'com.immich.widget.random', - 'com.immich.widget.memory', +// (iOSName, androidFQDN) +const List<(String, String)> kWidgetNames = [ + ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'), + ('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'), ]; diff --git a/mobile/lib/repositories/widget.repository.dart b/mobile/lib/repositories/widget.repository.dart index be314a281e..09532f4b78 100644 --- a/mobile/lib/repositories/widget.repository.dart +++ b/mobile/lib/repositories/widget.repository.dart @@ -10,8 +10,11 @@ class WidgetRepository { await HomeWidget.saveWidgetData(key, value); } - Future refresh(String name) async { - await HomeWidget.updateWidget(name: name, iOSName: name); + Future refresh(String iosName, String androidName) async { + await HomeWidget.updateWidget( + iOSName: iosName, + qualifiedAndroidName: androidName, + ); } Future setAppGroupId(String appGroupId) async { diff --git a/mobile/lib/services/widget.service.dart b/mobile/lib/services/widget.service.dart index 02ddedbe89..fb2022784f 100644 --- a/mobile/lib/services/widget.service.dart +++ b/mobile/lib/services/widget.service.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/repositories/widget.repository.dart'; @@ -33,10 +32,8 @@ class WidgetService { } Future refreshWidgets() async { - if (Platform.isAndroid) return; - - for (final name in kWidgetNames) { - await _repository.refresh(name); + for (final (iOSName, androidName) in kWidgetNames) { + await _repository.refresh(iOSName, androidName); } } } From 493d85b02103f371c83f9cc2aac93e2bdc46b7f1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 18 Jul 2025 10:57:29 -0400 Subject: [PATCH 25/45] feat!: absolute file paths (#19995) feat: absolute file paths --- docs/docs/administration/server-commands.md | 42 ++- docs/docs/install/environment-variables.md | 28 +- server/src/app.module.ts | 4 +- server/src/commands/index.ts | 10 +- server/src/commands/media-location.command.ts | 106 +++++++ server/src/constants.ts | 2 +- server/src/dtos/env.dto.ts | 6 +- server/src/enum.ts | 2 + server/src/queries/asset.repository.sql | 24 ++ server/src/queries/person.repository.sql | 11 + server/src/queries/user.repository.sql | 11 + server/src/repositories/asset.repository.ts | 18 ++ .../repositories/config.repository.spec.ts | 12 +- server/src/repositories/config.repository.ts | 8 +- .../src/repositories/database.repository.ts | 33 +++ server/src/repositories/person.repository.ts | 10 + server/src/repositories/user.repository.ts | 10 + .../1752759108283-ConvertToAbsolutePaths.ts | 39 +++ .../src/services/asset-media.service.spec.ts | 10 +- server/src/services/auth.service.spec.ts | 2 +- server/src/services/cli.service.ts | 58 ++++ server/src/services/download.service.spec.ts | 15 +- server/src/services/library.service.spec.ts | 4 +- server/src/services/media.service.spec.ts | 273 +++++++++--------- server/src/services/metadata.service.spec.ts | 6 +- server/src/services/server.service.spec.ts | 12 +- .../services/storage-template.service.spec.ts | 72 +++-- server/src/services/storage.service.spec.ts | 58 ++-- server/src/services/storage.service.ts | 19 +- server/src/services/user.service.spec.ts | 27 +- server/src/types.ts | 2 + server/src/utils/file.ts | 10 +- .../repositories/asset.repository.mock.ts | 1 + .../repositories/database.repository.mock.ts | 1 + 34 files changed, 689 insertions(+), 257 deletions(-) create mode 100644 server/src/commands/media-location.command.ts create mode 100644 server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md index b414f5deaa..b275d8fede 100644 --- a/docs/docs/administration/server-commands.md +++ b/docs/docs/administration/server-commands.md @@ -2,16 +2,17 @@ The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands: -| Command | Description | -| ------------------------ | ------------------------------------- | -| `help` | Display help | -| `reset-admin-password` | Reset the password for the admin user | -| `disable-password-login` | Disable password login | -| `enable-password-login` | Enable password login | -| `enable-oauth-login` | Enable OAuth login | -| `disable-oauth-login` | Disable OAuth login | -| `list-users` | List Immich users | -| `version` | Print Immich version | +| Command | Description | +| ------------------------ | ------------------------------------------------------------- | +| `help` | Display help | +| `reset-admin-password` | Reset the password for the admin user | +| `disable-password-login` | Disable password login | +| `enable-password-login` | Enable password login | +| `enable-oauth-login` | Enable OAuth login | +| `disable-oauth-login` | Disable OAuth login | +| `list-users` | List Immich users | +| `version` | Print Immich version | +| `change-media-location` | Change database file paths to align with a new media location | ## How to run a command @@ -88,3 +89,24 @@ Print Immich Version immich-admin version v1.129.0 ``` + +Change media location + +``` +immich-admin change-media-location +? Enter the previous value of IMMICH_MEDIA_LOCATION: /usr/src/app/upload +? Enter the new value of IMMICH_MEDIA_LOCATION: /data + + Previous value: /usr/src/app/upload + Current value: /data + + Changing database paths from "/usr/src/app/upload/*" to "/data/*" + +? Do you want to proceed? [Y/n] y + +Database file paths updated successfully! 🎉 + +You may now set IMMICH_MEDIA_LOCATION=/data and restart! + +(please remember to update applicable volume mounts e.g. ${UPLOAD_LOCATION}:/data) +``` diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index b9dab5b9c8..8d5ab55049 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -29,20 +29,20 @@ These environment variables are used by the `docker-compose.yml` file and do **N ## General -| Variable | Description | Default | Containers | Workers | -| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | \*1 | server | microservices | -| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | -| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `./upload`\*3 | server | api, microservices | -| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | -| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | -| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | | -| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | -| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | -| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | -| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | -| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices | +| Variable | Description | Default | Containers | Workers | +| :---------------------------------- | :---------------------------------------------------------------------------------------- | :---------------------------------: | :----------------------- | :----------------- | +| `TZ` | Timezone | \*1 | server | microservices | +| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | +| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `/usr/src/app/upload`\*3 | server | api, microservices | +| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | +| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | +| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | | +| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | +| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | +| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | +| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | +| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices | \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 9ea75d78c4..8d261463e7 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -5,7 +5,7 @@ import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule'; import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; -import { commands } from 'src/commands'; +import { commandsAndQuestions } from 'src/commands'; import { IWorker } from 'src/constants'; import { controllers } from 'src/controllers'; import { ImmichWorker } from 'src/enum'; @@ -97,7 +97,7 @@ export class MicroservicesModule extends BaseModule {} @Module({ imports: [...imports], - providers: [...common, ...commands, SchedulerRegistry], + providers: [...common, ...commandsAndQuestions, SchedulerRegistry], }) export class ImmichAdminModule implements OnModuleDestroy { constructor(private service: CliService) {} diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts index ce085f6e34..46a8d13e35 100644 --- a/server/src/commands/index.ts +++ b/server/src/commands/index.ts @@ -1,11 +1,16 @@ import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin'; import { ListUsersCommand } from 'src/commands/list-users.command'; +import { + ChangeMediaLocationCommand, + PromptConfirmMoveQuestions, + PromptMediaLocationQuestions, +} from 'src/commands/media-location.command'; import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login'; import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login'; import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command'; import { VersionCommand } from 'src/commands/version.command'; -export const commands = [ +export const commandsAndQuestions = [ ResetAdminPasswordCommand, PromptPasswordQuestions, PromptEmailQuestion, @@ -17,4 +22,7 @@ export const commands = [ VersionCommand, GrantAdminCommand, RevokeAdminCommand, + ChangeMediaLocationCommand, + PromptMediaLocationQuestions, + PromptConfirmMoveQuestions, ]; diff --git a/server/src/commands/media-location.command.ts b/server/src/commands/media-location.command.ts new file mode 100644 index 0000000000..0935fe202d --- /dev/null +++ b/server/src/commands/media-location.command.ts @@ -0,0 +1,106 @@ +import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander'; +import { CliService } from 'src/services/cli.service'; + +@Command({ + name: 'change-media-location', + description: 'Change database file paths to align with a new media location', +}) +export class ChangeMediaLocationCommand extends CommandRunner { + constructor( + private service: CliService, + private inquirer: InquirerService, + ) { + super(); + } + + private async showSamplePaths(hint?: string) { + hint = hint ? ` (${hint})` : ''; + + const paths = await this.service.getSampleFilePaths(); + if (paths.length > 0) { + let message = ` Examples from the database${hint}:\n`; + for (const path of paths) { + message += ` - ${path}\n`; + } + + console.log(`\n${message}`); + } + } + + async run(): Promise { + try { + await this.showSamplePaths(); + + const { oldValue, newValue } = await this.inquirer.ask<{ oldValue: string; newValue: string }>( + 'prompt-media-location', + {}, + ); + + const success = await this.service.migrateFilePaths({ + oldValue, + newValue, + confirm: async ({ sourceFolder, targetFolder }) => { + console.log(` + Previous value: ${oldValue} + Current value: ${newValue} + + Changing from "${sourceFolder}/*" to "${targetFolder}/*" +`); + + const { value: confirmed } = await this.inquirer.ask<{ value: boolean }>('prompt-confirm-move', {}); + return confirmed; + }, + }); + + const successMessage = `Matching database file paths were updated successfully! 🎉 + + You may now set IMMICH_MEDIA_LOCATION=${newValue} and restart! + + (please remember to update applicable volume mounts e.g + services: + immich-server: + ... + volumes: + - \${UPLOAD_LOCATION}:/usr/src/app/upload + ... + )`; + + console.log(`\n ${success ? successMessage : 'No rows were updated'}\n`); + + await this.showSamplePaths('after'); + } catch (error) { + console.error(error); + console.error('Unable to update database file paths.'); + } + } +} + +const currentValue = process.env.IMMICH_MEDIA_LOCATION || ''; + +const makePrompt = (which: string) => { + return `Enter the ${which} value of IMMICH_MEDIA_LOCATION:${currentValue ? ` [${currentValue}]` : ''}`; +}; + +@QuestionSet({ name: 'prompt-media-location' }) +export class PromptMediaLocationQuestions { + @Question({ message: makePrompt('previous'), name: 'oldValue' }) + oldValue(value: string) { + return value || currentValue; + } + + @Question({ message: makePrompt('new'), name: 'newValue' }) + newValue(value: string) { + return value || currentValue; + } +} + +@QuestionSet({ name: 'prompt-confirm-move' }) +export class PromptConfirmMoveQuestions { + @Question({ + message: 'Do you want to proceed? [Y/n]', + name: 'value', + }) + value(value: string): boolean { + return ['yes', 'y'].includes((value || 'y').toLowerCase()); + } +} diff --git a/server/src/constants.ts b/server/src/constants.ts index 447d8a09c9..2d803c2e95 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -47,7 +47,7 @@ export const serverVersion = new SemVer(version); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); -export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; +export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || '/usr/src/app/upload'; export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000); export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number( diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 99fd1d2149..3543d8dae9 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -1,5 +1,5 @@ import { Transform, Type } from 'class-transformer'; -import { IsEnum, IsInt, IsString } from 'class-validator'; +import { IsEnum, IsInt, IsString, Matches } from 'class-validator'; import { DatabaseSslMode, ImmichEnvironment, LogLevel } from 'src/enum'; import { IsIPRange, Optional, ValidateBoolean } from 'src/validation'; @@ -48,6 +48,10 @@ export class EnvDto { @Optional() IMMICH_LOG_LEVEL?: LogLevel; + @Optional() + @Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' }) + IMMICH_MEDIA_LOCATION?: string; + @IsInt() @Optional() @Type(() => Number) diff --git a/server/src/enum.ts b/server/src/enum.ts index 587a76126f..e41a790999 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -198,6 +198,7 @@ export enum StorageFolder { } export enum SystemMetadataKey { + MediaLocation = 'MediaLocation', ReverseGeocodingState = 'reverse-geocoding-state', FacialRecognitionState = 'facial-recognition-state', MemoriesState = 'memories-state', @@ -544,6 +545,7 @@ export enum DatabaseLock { CLIPDimSize = 512, Library = 1337, NightlyJobs = 600, + MediaLocation = 700, GetSystemConfig = 69, BackupDatabase = 42, MemoryCreation = 777, diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index e482a38a9a..9425bd9a11 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -168,6 +168,30 @@ from where "livePhotoVideoId" = $1::uuid +-- AssetRepository.getFileSamples +select + "asset"."id", + "asset"."originalPath", + "asset"."sidecarPath", + "asset"."encodedVideoPath", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "path" + from + "asset_file" + where + "asset"."id" = "asset_file"."assetId" + ) as agg + ) as "files" +from + "asset" +limit + 3 + -- AssetRepository.getById select "asset".* diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 3e41edde9c..8ad5b96bbc 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -12,6 +12,17 @@ delete from "person" where "person"."id" in ($1) +-- PersonRepository.getFileSamples +select + "id", + "thumbnailPath" +from + "person" +where + "thumbnailPath" != '' +limit + 3 + -- PersonRepository.getAllForUser select "person".* diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index f1809464bf..6a02654781 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -78,6 +78,17 @@ where "user"."isAdmin" = $1 and "user"."deletedAt" is null +-- UserRepository.getFileSamples +select + "id", + "profileImagePath" +from + "user" +where + "profileImagePath" != '' +limit + 3 + -- UserRepository.hasAdmin select "user"."id" diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index cb7e804f6f..edbafaa22d 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely'; +import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Stack } from 'src/database'; @@ -335,6 +336,23 @@ export class AssetRepository { return count; } + @GenerateSql() + getFileSamples() { + return this.db + .selectFrom('asset') + .select((eb) => [ + 'asset.id', + 'asset.originalPath', + 'asset.sidecarPath', + 'asset.encodedVideoPath', + jsonArrayFrom(eb.selectFrom('asset_file').select('path').whereRef('asset.id', '=', 'asset_file.assetId')).as( + 'files', + ), + ]) + .limit(sql.lit(3)) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID] }) getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) { return this.db diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index a096c6a4bc..99cba43b99 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -13,6 +13,7 @@ const resetEnv = () => { 'IMMICH_WORKERS_EXCLUDE', 'IMMICH_TRUSTED_PROXIES', 'IMMICH_API_METRICS_PORT', + 'IMMICH_MEDIA_LOCATION', 'IMMICH_MICROSERVICES_METRICS_PORT', 'IMMICH_TELEMETRY_INCLUDE', 'IMMICH_TELEMETRY_EXCLUDE', @@ -76,6 +77,13 @@ describe('getEnv', () => { }); }); + describe('IMMICH_MEDIA_LOCATION', () => { + it('should throw an error for relative paths', () => { + process.env.IMMICH_MEDIA_LOCATION = './relative/path'; + expect(() => getEnv()).toThrowError('IMMICH_MEDIA_LOCATION must be an absolute path'); + }); + }); + describe('database', () => { it('should use defaults', () => { const { database } = getEnv(); @@ -95,7 +103,7 @@ describe('getEnv', () => { it('should validate DB_SSL_MODE', () => { process.env.DB_SSL_MODE = 'invalid'; - expect(() => getEnv()).toThrowError('Invalid environment variables: DB_SSL_MODE'); + expect(() => getEnv()).toThrowError('DB_SSL_MODE must be one of the following values:'); }); it('should accept a valid DB_SSL_MODE', () => { @@ -239,7 +247,7 @@ describe('getEnv', () => { it('should reject invalid trusted proxies', () => { process.env.IMMICH_TRUSTED_PROXIES = '10.1'; - expect(() => getEnv()).toThrowError('Invalid environment variables: IMMICH_TRUSTED_PROXIES'); + expect(() => getEnv()).toThrow('IMMICH_TRUSTED_PROXIES must be an ip address, or ip address range'); }); }); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 7038338927..c9e96a1803 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -131,9 +131,11 @@ const getEnv = (): EnvData => { const dto = plainToInstance(EnvDto, process.env); const errors = validateSync(dto); if (errors.length > 0) { - throw new Error( - `Invalid environment variables: ${errors.map((error) => `${error.property}=${error.value}`).join(', ')}`, - ); + const messages = [`Invalid environment variables: `]; + for (const error of errors) { + messages.push(` - ${error.property}=${error.value} (${Object.values(error.constraints || {}).join(', ')})`); + } + throw new Error(messages.join('\n')); } const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]); diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index 8b5c728ce4..1f83630cfa 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -436,6 +436,39 @@ export class DatabaseRepository { this.logger.debug('Finished running kysely migrations'); } + async migrateFilePaths(sourceFolder: string, targetFolder: string): Promise { + // escaping regex special characters with a backslash + const sourceRegex = '^' + sourceFolder.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, String.raw`\$&`); + const source = sql.raw(`'${sourceRegex}'`); + const target = sql.lit(targetFolder); + + await this.db.transaction().execute(async (tx) => { + await tx + .updateTable('asset') + .set((eb) => ({ + originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]), + encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]), + sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]), + })) + .execute(); + + await tx + .updateTable('asset_file') + .set((eb) => ({ path: eb.fn('REGEXP_REPLACE', ['path', source, target]) })) + .execute(); + + await tx + .updateTable('person') + .set((eb) => ({ thumbnailPath: eb.fn('REGEXP_REPLACE', ['thumbnailPath', source, target]) })) + .execute(); + + await tx + .updateTable('user') + .set((eb) => ({ profileImagePath: eb.fn('REGEXP_REPLACE', ['profileImagePath', source, target]) })) + .execute(); + }); + } + async withLock(lock: DatabaseLock, callback: () => Promise): Promise { let res; await this.asyncLock.acquire(DatabaseLock[lock], async () => { diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 5b7d1d3700..f653bb8179 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -142,6 +142,16 @@ export class PersonRepository { .stream(); } + @GenerateSql() + getFileSamples() { + return this.db + .selectFrom('person') + .select(['id', 'thumbnailPath']) + .where('thumbnailPath', '!=', sql.lit('')) + .limit(sql.lit(3)) + .execute(); + } + @GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] }) async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) { const items = await this.db diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 715aa2dc32..9d5f19b26a 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -79,6 +79,16 @@ export class UserRepository { .executeTakeFirst(); } + @GenerateSql() + getFileSamples() { + return this.db + .selectFrom('user') + .select(['id', 'profileImagePath']) + .where('profileImagePath', '!=', sql.lit('')) + .limit(sql.lit(3)) + .execute(); + } + @GenerateSql() async hasAdmin(): Promise { const admin = await this.db diff --git a/server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts b/server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts new file mode 100644 index 0000000000..68b0c7931e --- /dev/null +++ b/server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts @@ -0,0 +1,39 @@ +import { Kysely, sql } from 'kysely'; +import { LoggingRepository } from 'src/repositories/logging.repository'; + +const logger = LoggingRepository.create(); +logger.setContext('Migrations'); + +export async function up(db: Kysely): Promise { + if (process.env.IMMICH_MEDIA_LOCATION) { + // do not automatically convert paths for a custom location/setting + return; + } + + // we construct paths using `path.join(mediaLocation, ...)`, which strips the leading './' + const source = 'upload'; + const target = '/usr/src/app/upload'; + + logger.log(`Converting database file paths from relative to absolute (source=${source}/*, target=${target}/*)`); + + // escaping regex special characters with a backslash + const sourceRegex = '^' + source.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, String.raw`\$&`); + + const items: Array<{ table: string; column: string }> = [ + { table: 'asset', column: 'originalPath' }, + { table: 'asset', column: 'encodedVideoPath' }, + { table: 'asset', column: 'sidecarPath' }, + { table: 'asset_file', column: 'path' }, + { table: 'person', column: 'thumbnailPath' }, + { table: 'user', column: 'profileImagePath' }, + ]; + + for (const { table, column } of items) { + const query = `UPDATE "${table}" SET "${column}" = REGEXP_REPLACE("${column}", '${sourceRegex}', '${target}') WHERE "${column}" IS NOT NULL`; + await sql.raw(query).execute(db); + } +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index dccb79c585..08b42b6cbf 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -294,16 +294,16 @@ describe(AssetMediaService.name, () => { it('should return profile for profile uploads', () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( - 'upload/profile/admin_id', + expect.stringContaining('upload/profile/admin_id'), ); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/profile/admin_id')); }); it('should return upload for everything else', () => { expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( - 'upload/upload/admin_id/ra/nd', + expect.stringContaining('upload/upload/admin_id/ra/nd'), ); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/upload/admin_id/ra/nd')); }); }); @@ -913,7 +913,7 @@ describe(AssetMediaService.name, () => { expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, - data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] }, + data: { files: [expect.stringContaining('upload/upload/user-id/ra/nd/random-uuid.jpg')] }, }); }); }); diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f52fd9dd81..129877bbdd 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -789,7 +789,7 @@ describe(AuthService.name, () => { ).resolves.toEqual(oauthResponse(user)); expect(mocks.user.update).toHaveBeenCalledWith(user.id, { - profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`, + profileImagePath: expect.stringContaining(`upload/profile/${user.id}/${fileId}.jpg`), profileChangedAt: expect.any(Date), }); expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl); diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts index 021a5240f6..674b885dc4 100644 --- a/server/src/services/cli.service.ts +++ b/server/src/services/cli.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { isAbsolute } from 'node:path'; import { SALT_ROUNDS } from 'src/constants'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { BaseService } from 'src/services/base.service'; @@ -67,6 +68,63 @@ export class CliService extends BaseService { await this.updateConfig(config); } + async getSampleFilePaths(): Promise { + const [assets, people, users] = await Promise.all([ + this.assetRepository.getFileSamples(), + this.personRepository.getFileSamples(), + this.userRepository.getFileSamples(), + ]); + + const paths = []; + + for (const person of people) { + paths.push(person.thumbnailPath); + } + + for (const user of users) { + paths.push(user.profileImagePath); + } + + for (const asset of assets) { + paths.push( + asset.originalPath, + asset.sidecarPath, + asset.encodedVideoPath, + ...asset.files.map((file) => file.path), + ); + } + + return paths.filter(Boolean) as string[]; + } + + async migrateFilePaths({ + oldValue, + newValue, + confirm, + }: { + oldValue: string; + newValue: string; + confirm: (data: { sourceFolder: string; targetFolder: string }) => Promise; + }): Promise { + let sourceFolder = oldValue; + if (sourceFolder.startsWith('./')) { + sourceFolder = sourceFolder.slice(2); + } + + const targetFolder = newValue; + if (!isAbsolute(targetFolder)) { + throw new Error('Target media location must be an absolute path'); + } + + if (!(await confirm({ sourceFolder, targetFolder }))) { + return false; + } + + await this.databaseRepository.migrateFilePaths(sourceFolder, targetFolder); + + return true; + } + cleanup() { return this.databaseRepository.shutdown(); } diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 7646637093..940767ff67 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; +import { APP_MEDIA_LOCATION } from 'src/constants'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadService } from 'src/services/download.service'; import { assetStub } from 'test/fixtures/asset.stub'; @@ -46,7 +47,11 @@ describe(DownloadService.name, () => { }); expect(archiveMock.addFile).toHaveBeenCalledTimes(1); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('upload/library/IMG_123.jpg'), + 'IMG_123.jpg', + ); }); it('should log a warning if the original path could not be resolved', async () => { @@ -279,9 +284,15 @@ describe(DownloadService.name, () => { mocks.downloadRepository.downloadAssetIds.mockReturnValue( makeStream([{ id: 'asset-1', livePhotoVideoId: 'asset-3', size: 5000 }]), ); + mocks.downloadRepository.downloadMotionAssetIds.mockReturnValue( makeStream([ - { id: 'asset-2', livePhotoVideoId: null, size: 23_456, originalPath: 'upload/encoded-video/uuid-MP.mp4' }, + { + id: 'asset-2', + livePhotoVideoId: null, + size: 23_456, + originalPath: APP_MEDIA_LOCATION + '/encoded-video/uuid-MP.mp4', + }, ]), ); diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index dac2b64ebc..308c80fb37 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; -import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; +import { APP_MEDIA_LOCATION, JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { mapLibrary } from 'src/dtos/library.dto'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { LibraryService } from 'src/services/library.service'; @@ -1264,7 +1264,7 @@ describe(LibraryService.name, () => { }); it('should detect when import path is in immich media folder', async () => { - const importPaths = ['upload/thumbs', `${process.cwd()}/xyz`, 'upload/library']; + const importPaths = [APP_MEDIA_LOCATION + '/thumbs', `${process.cwd()}/xyz`, APP_MEDIA_LOCATION + '/library']; const library = factory.library({ importPaths }); mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d3c361bb8a..0f4ba769c0 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,5 +1,6 @@ import { OutputInfo } from 'sharp'; import { SystemConfig } from 'src/config'; +import { APP_MEDIA_LOCATION } from 'src/constants'; import { Exif } from 'src/database'; import { AssetFileType, @@ -204,19 +205,19 @@ describe(MediaService.name, () => { entityId: assetStub.image.id, pathType: AssetPathType.FullSize, oldPath: '/uploads/user-id/fullsize/path.webp', - newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg'), }); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, pathType: AssetPathType.Preview, oldPath: '/uploads/user-id/thumbs/path.jpg', - newPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-preview.jpeg'), }); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: assetStub.image.id, pathType: AssetPathType.Thumbnail, oldPath: '/uploads/user-id/webp/path.ext', - newPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-thumbnail.webp'), }); expect(mocks.move.create).toHaveBeenCalledTimes(3); }); @@ -295,7 +296,7 @@ describe(MediaService.name, () => { await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { @@ -315,7 +316,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, @@ -327,7 +328,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + expect.any(String), ); expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); @@ -341,12 +342,12 @@ describe(MediaService.name, () => { { assetId: 'asset-id', type: AssetFileType.Preview, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + path: expect.any(String), }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + path: expect.any(String), }, ]); expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer }); @@ -357,10 +358,10 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ @@ -377,12 +378,12 @@ describe(MediaService.name, () => { { assetId: 'asset-id', type: AssetFileType.Preview, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + path: expect.any(String), }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + path: expect.any(String), }, ]); }); @@ -392,10 +393,10 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video); await sut.handleGenerateThumbnails({ id: assetStub.video.id }); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ @@ -412,12 +413,12 @@ describe(MediaService.name, () => { { assetId: 'asset-id', type: AssetFileType.Preview, - path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + path: expect.any(String), }, { assetId: 'asset-id', type: AssetFileType.Thumbnail, - path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + path: expect.any(String), }, ]); }); @@ -432,7 +433,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ @@ -453,7 +454,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), expect.objectContaining({ inputOptions: ['-sws_flags accurate_rnd+full_chroma_int'], outputOptions: expect.any(Array), @@ -471,7 +472,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), @@ -485,12 +486,12 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`; + const previewPath = APP_MEDIA_LOCATION + `/thumbs/user-id/as/se/asset-id-preview.${format}`; + const thumbnailPath = APP_MEDIA_LOCATION + `/thumbs/user-id/as/se/asset-id-thumbnail.webp`; await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.Srgb, @@ -530,12 +531,12 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); - const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`; - const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; + const previewPath = expect.stringContaining(`upload/thumbs/user-id/as/se/asset-id-preview.jpeg`); + const thumbnailPath = expect.stringContaining(`upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, { colorspace: Colorspace.Srgb, @@ -658,12 +659,12 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: false }), - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, expect.objectContaining({ processInvalidImages: false }), - 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', + expect.any(String), ); expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce(); @@ -704,7 +705,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), ); }); @@ -734,7 +735,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp', + expect.any(String), ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( fullsizeBuffer, @@ -746,7 +747,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), ); }); @@ -774,7 +775,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + expect.any(String), ); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( rawBuffer, @@ -786,7 +787,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', + expect.any(String), ); }); @@ -815,7 +816,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + expect.any(String), ); }); @@ -838,7 +839,7 @@ describe(MediaService.name, () => { expect(mocks.media.generateThumbnail).not.toHaveBeenCalledWith( expect.anything(), expect.anything(), - 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg', + expect.stringContaining('fullsize.jpeg'), ); }); @@ -869,7 +870,7 @@ describe(MediaService.name, () => { processInvalidImages: false, raw: rawInfo, }, - 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp', + expect.any(String), ); }); }); @@ -911,7 +912,7 @@ describe(MediaService.name, () => { ); expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, { colorspace: Colorspace.P3, orientation: undefined, @@ -933,12 +934,9 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ - id: 'person-1', - thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) }); }); it('should use preview path if video', async () => { @@ -953,7 +951,7 @@ describe(MediaService.name, () => { ); expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String)); expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, { colorspace: Colorspace.P3, orientation: undefined, @@ -975,12 +973,9 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); - expect(mocks.person.update).toHaveBeenCalledWith({ - id: 'person-1', - thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', - }); + expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) }); }); it('should generate a thumbnail without going negative', async () => { @@ -1015,7 +1010,7 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); }); @@ -1052,7 +1047,7 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); }); @@ -1089,7 +1084,7 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); }); @@ -1126,7 +1121,7 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); }); @@ -1168,7 +1163,7 @@ describe(MediaService.name, () => { processInvalidImages: false, size: 250, }, - 'upload/thumbs/admin_id/pe/rs/person-1.jpeg', + expect.any(String), ); }); @@ -1288,7 +1283,7 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalled(); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:1', '-map 0:3']), @@ -1308,7 +1303,7 @@ describe(MediaService.name, () => { expect(mocks.storage.mkdirSync).toHaveBeenCalled(); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:2']), @@ -1354,7 +1349,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), @@ -1369,7 +1364,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), @@ -1384,7 +1379,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), @@ -1399,7 +1394,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), @@ -1416,7 +1411,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), @@ -1431,7 +1426,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), @@ -1446,7 +1441,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), @@ -1463,7 +1458,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), @@ -1480,7 +1475,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), @@ -1497,7 +1492,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), @@ -1518,7 +1513,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), @@ -1539,7 +1534,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), @@ -1554,7 +1549,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), @@ -1568,7 +1563,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), @@ -1627,7 +1622,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), @@ -1642,7 +1637,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), @@ -1657,7 +1652,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), @@ -1672,7 +1667,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']), @@ -1693,7 +1688,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), @@ -1714,7 +1709,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), @@ -1729,7 +1724,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), @@ -1744,7 +1739,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), @@ -1759,7 +1754,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), @@ -1774,7 +1769,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), @@ -1789,7 +1784,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), @@ -1804,7 +1799,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), @@ -1819,7 +1814,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), @@ -1834,7 +1829,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ @@ -1859,7 +1854,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), @@ -1874,7 +1869,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), @@ -1889,7 +1884,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), @@ -1906,7 +1901,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), @@ -1950,7 +1945,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ @@ -1987,7 +1982,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), @@ -2004,7 +1999,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), @@ -2021,7 +2016,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), @@ -2038,7 +2033,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), @@ -2053,7 +2048,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), @@ -2070,7 +2065,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel cuda', @@ -2092,7 +2087,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([ @@ -2113,7 +2108,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), @@ -2130,7 +2125,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', @@ -2170,7 +2165,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', @@ -2190,7 +2185,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', @@ -2210,7 +2205,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', @@ -2239,7 +2234,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD129', @@ -2261,7 +2256,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', @@ -2287,7 +2282,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', @@ -2315,7 +2310,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), @@ -2334,7 +2329,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel qsv', @@ -2354,7 +2349,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', @@ -2386,7 +2381,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', @@ -2410,7 +2405,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', @@ -2436,7 +2431,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', @@ -2455,7 +2450,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD129', @@ -2476,7 +2471,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', @@ -2498,7 +2493,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel vaapi', @@ -2523,7 +2518,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']), outputOptions: expect.arrayContaining([ @@ -2546,7 +2541,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']), outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]), @@ -2565,7 +2560,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_device /dev/dri/renderD129']), outputOptions: expect.any(Array), @@ -2584,7 +2579,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledTimes(2); expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', @@ -2607,7 +2602,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledTimes(3); expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), @@ -2624,7 +2619,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledTimes(2); expect(mocks.media.transcode).toHaveBeenLastCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), @@ -2649,7 +2644,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining([ '-hwaccel rkmpp', @@ -2689,7 +2684,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), @@ -2706,7 +2701,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), @@ -2723,7 +2718,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ @@ -2745,7 +2740,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), outputOptions: expect.arrayContaining([ @@ -2764,7 +2759,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ @@ -2786,7 +2781,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ @@ -2805,7 +2800,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ @@ -2824,7 +2819,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ @@ -2843,7 +2838,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf format=yuv420p']), @@ -2858,7 +2853,7 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + expect.any(String), expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf scale=-2:720,format=yuv420p']), @@ -2874,19 +2869,15 @@ describe(MediaService.name, () => { await sut.handleVideoConversion({ id: assetStub.video.id }); expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); - expect(mocks.media.transcode).toHaveBeenCalledWith( - assetStub.video.originalPath, - 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { - inputOptions: expect.any(Array), - outputOptions: expect.any(Array), - twoPass: false, - progress: { - frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, - percentInterval: expect.any(Number), - }, + expect(mocks.media.transcode).toHaveBeenCalledWith(assetStub.video.originalPath, expect.any(String), { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), }, - ); + }); }); it('should not count frames for progress when log level is not debug', async () => { @@ -2904,7 +2895,7 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).toHaveBeenCalledWith( '/original/path.ext', - 'upload/encoded-video/user-id/as/se/asset-id.mp4', + APP_MEDIA_LOCATION + '/encoded-video/user-id/as/se/asset-id.mp4', expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:a copy']), diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 77f0b50a0a..cc0956b9a8 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -587,7 +587,7 @@ describe(MetadataService.name, () => { libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', - originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4', + originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.Video, }); @@ -645,7 +645,7 @@ describe(MetadataService.name, () => { libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', - originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4', + originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.Video, }); @@ -703,7 +703,7 @@ describe(MetadataService.name, () => { libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, originalFileName: 'asset_1.mp4', - originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4', + originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, type: AssetType.Video, }); diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 0ddf3d69b1..06ddd32601 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -28,7 +28,7 @@ describe(ServerService.name, () => { diskUseRaw: 300, }); - expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library')); }); it('should return the disk space as KiB', async () => { @@ -44,7 +44,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000, }); - expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library')); }); it('should return the disk space as MiB', async () => { @@ -60,7 +60,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000, }); - expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library')); }); it('should return the disk space as GiB', async () => { @@ -80,7 +80,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000, }); - expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library')); }); it('should return the disk space as TiB', async () => { @@ -100,7 +100,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000, }); - expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library')); }); it('should return the disk space as PiB', async () => { @@ -120,7 +120,7 @@ describe(ServerService.name, () => { diskUseRaw: 300_000_000_000_000_000, }); - expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library'); + expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library')); }); }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 2751651dbf..882ffcd328 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -1,5 +1,6 @@ import { Stats } from 'node:fs'; import { defaults, SystemConfig } from 'src/config'; +import { APP_MEDIA_LOCATION } from 'src/constants'; import { AssetPathType, JobStatus } from 'src/enum'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { albumStub } from 'test/fixtures/album.stub'; @@ -110,8 +111,10 @@ describe(StorageTemplateService.name, () => { it('should migrate single moving picture', async () => { mocks.user.get.mockResolvedValue(userStub.user1); - const newMotionPicturePath = `upload/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; - const newStillPicturePath = `upload/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; + const newMotionPicturePath = + APP_MEDIA_LOCATION + `/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; + const newStillPicturePath = + APP_MEDIA_LOCATION + `/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`; mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); @@ -156,7 +159,9 @@ describe(StorageTemplateService.name, () => { expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, - newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, + newPath: expect.stringContaining( + `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`, + ), oldPath: asset.originalPath, pathType: AssetPathType.Original, }); @@ -177,7 +182,9 @@ describe(StorageTemplateService.name, () => { const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, - newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, + newPath: expect.stringContaining( + `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`, + ), oldPath: asset.originalPath, pathType: AssetPathType.Original, }); @@ -211,7 +218,9 @@ describe(StorageTemplateService.name, () => { const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, - newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`, + newPath: expect.stringContaining( + `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`, + ), oldPath: asset.originalPath, pathType: AssetPathType.Original, }); @@ -234,7 +243,9 @@ describe(StorageTemplateService.name, () => { const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); expect(mocks.move.create).toHaveBeenCalledWith({ entityId: asset.id, - newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`, + newPath: + APP_MEDIA_LOCATION + + `/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`, oldPath: asset.originalPath, pathType: AssetPathType.Original, }); @@ -244,8 +255,9 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(userStub.user1); const asset = assetStub.storageAsset(); - const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`; - const newPath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`; + const previousFailedNewPath = + APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`; + const newPath = APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`; mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath)); mocks.move.getByEntity.mockResolvedValue({ @@ -284,8 +296,9 @@ describe(StorageTemplateService.name, () => { mocks.user.get.mockResolvedValue(userStub.user1); const asset = assetStub.storageAsset({ fileSizeInByte: 5000 }); - const previousFailedNewPath = `upload/library/${asset.ownerId}/2022/June/${asset.originalFileName}`; - const newPath = `upload/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; + const previousFailedNewPath = + APP_MEDIA_LOCATION + `/library/${asset.ownerId}/2022/June/${asset.originalFileName}`; + const newPath = APP_MEDIA_LOCATION + `/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`; mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); @@ -319,7 +332,8 @@ describe(StorageTemplateService.name, () => { it('should fail move if copying and hash of asset and the new file do not match', async () => { mocks.user.get.mockResolvedValue(userStub.user1); - const newPath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`; + const newPath = + APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`; mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); @@ -409,7 +423,7 @@ describe(StorageTemplateService.name, () => { it('should handle an asset with a duplicate destination', async () => { const asset = assetStub.storageAsset(); const oldPath = asset.originalPath; - const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`; const newPath2 = newPath.replace('.jpg', '+1.jpg'); mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); @@ -466,7 +480,7 @@ describe(StorageTemplateService.name, () => { it('should move an asset', async () => { const asset = assetStub.storageAsset(); const oldPath = asset.originalPath; - const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`; mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.move.create.mockResolvedValue({ @@ -502,18 +516,20 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', - `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`, + expect.stringContaining(`upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, - originalPath: `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`, + originalPath: expect.stringContaining( + `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`, + ), }); }); it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => { const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 }); const oldPath = asset.originalPath; - const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`; + const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`; mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.user.getList.mockResolvedValue([userStub.user1]); @@ -572,14 +588,14 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', - `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, + expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.storage.copyFile).toHaveBeenCalledWith( '/original/path.jpg', - `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, + expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.storage.stat).toHaveBeenCalledWith( - `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, + expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -603,7 +619,7 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( '/original/path.jpg', - `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`, + expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`), ); expect(mocks.asset.update).not.toHaveBeenCalled(); }); @@ -631,8 +647,8 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, - `upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`, + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`), + expect.stringContaining(`upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`), ); }); @@ -657,8 +673,8 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`), + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`), ); }); @@ -683,8 +699,8 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`, + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`), + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`), ); }); @@ -709,8 +725,8 @@ describe(StorageTemplateService.name, () => { expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.storage.rename).toHaveBeenCalledWith( - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`, - `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`, + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`), + expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`), ); }); }); diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 0e051f2642..567b78ac09 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -32,18 +32,36 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile'); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload'); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/encoded-video')); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library')); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/profile')); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/thumbs')); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/upload')); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups')); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/encoded-video/.immich'), + expect.any(Buffer), + ); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/library/.immich'), + expect.any(Buffer), + ); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/profile/.immich'), + expect.any(Buffer), + ); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/thumbs/.immich'), + expect.any(Buffer), + ); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/upload/.immich'), + expect.any(Buffer), + ); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/backups/.immich'), + expect.any(Buffer), + ); }); it('should enable mount folder checking for a new folder type', async () => { @@ -71,11 +89,17 @@ describe(StorageService.name, () => { }, }); expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library')); + expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups')); expect(mocks.storage.createFile).toHaveBeenCalledTimes(2); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); - expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/library/.immich'), + expect.any(Buffer), + ); + expect(mocks.storage.createFile).toHaveBeenCalledWith( + expect.stringContaining('upload/backups/.immich'), + expect.any(Buffer), + ); }); it('should throw an error if .immich is missing', async () => { @@ -131,7 +155,7 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(mocks.systemMetadata.set).not.toHaveBeenCalled(); + expect(mocks.systemMetadata.set).not.toHaveBeenCalledWith(SystemMetadataKey.SystemFlags, expect.anything()); }); }); diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 6e3ef4820a..632e0c1385 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { join, resolve } from 'node:path'; +import { join } from 'node:path'; +import { APP_MEDIA_LOCATION } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; import { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum'; @@ -60,6 +61,17 @@ export class StorageService extends BaseService { } } }); + + await this.databaseRepository.withLock(DatabaseLock.MediaLocation, async () => { + const current = APP_MEDIA_LOCATION; + const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation); + const previous = savedValue?.location || ''; + + if (previous !== current) { + this.logger.log(`Media location changed (from=${previous}, to=${current})`); + await this.systemMetadataRepository.set(SystemMetadataKey.MediaLocation, { location: current }); + } + }); } @OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask }) @@ -87,9 +99,8 @@ export class StorageService extends BaseService { try { await this.storageRepository.readFile(internalPath); } catch (error) { - const fullyQualifiedPath = resolve(process.cwd(), internalPath); - this.logger.error(`Failed to read ${fullyQualifiedPath} (${internalPath}): ${error}`); - throw new ImmichStartupError(`Failed to read: "${externalPath} (${fullyQualifiedPath}) - ${docsMessage}"`); + this.logger.error(`Failed to read (${internalPath}): ${error}`); + throw new ImmichStartupError(`Failed to read: "${externalPath} (${internalPath}) - ${docsMessage}"`); } } diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 3a85389ace..b4e616974e 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -235,11 +235,26 @@ describe(UserService.name, () => { await sut.handleUserDelete({ id: user.id }); - expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); - expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); - expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); - expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); - expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( + expect.stringContaining('upload/library/deleted-user'), + options, + ); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( + expect.stringContaining('upload/upload/deleted-user'), + options, + ); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( + expect.stringContaining('upload/profile/deleted-user'), + options, + ); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( + expect.stringContaining('upload/thumbs/deleted-user'), + options, + ); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( + expect.stringContaining('upload/encoded-video/deleted-user'), + options, + ); expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id); expect(mocks.user.delete).toHaveBeenCalledWith(user, true); }); @@ -253,7 +268,7 @@ describe(UserService.name, () => { const options = { force: true, recursive: true }; - expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options); + expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(expect.stringContaining('upload/library/admin'), options); }); }); diff --git a/server/src/types.ts b/server/src/types.ts index 79aeedd47d..9cd1aa996b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -451,11 +451,13 @@ export type MemoriesState = { /** memories have already been created through this date */ lastOnThisDayDate: string; }; +export type MediaLocation = { location: string }; export interface SystemMetadata extends Record> { [SystemMetadataKey.AdminOnboarding]: { isOnboarded: boolean }; [SystemMetadataKey.FacialRecognitionState]: { lastRun?: string }; [SystemMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: Date }; + [SystemMetadataKey.MediaLocation]: MediaLocation; [SystemMetadataKey.ReverseGeocodingState]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.SystemConfig]: DeepPartial; [SystemMetadataKey.SystemFlags]: DeepPartial; diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 3e1a1b7f68..2331a45a62 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -1,7 +1,7 @@ import { HttpException, StreamableFile } from '@nestjs/common'; import { NextFunction, Response } from 'express'; import { access, constants } from 'node:fs/promises'; -import { basename, extname, isAbsolute } from 'node:path'; +import { basename, extname } from 'node:path'; import { promisify } from 'node:util'; import { CacheControl } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -62,15 +62,9 @@ export const sendFile = async ( res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`); } - // configure options for serving - const options: SendFileOptions = { dotfiles: 'allow' }; - if (!isAbsolute(file.path)) { - options.root = process.cwd(); - } - await access(file.path, constants.R_OK); - return await _sendFile(file.path, options); + return await _sendFile(file.path, { dotfiles: 'allow' }); } catch (error: Error | any) { // ignore client-closed connection if (isConnectionAborted(error) || res.headersSent) { diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index a29babbf54..6fca29d98e 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked Date: Fri, 18 Jul 2025 10:57:49 -0400 Subject: [PATCH 26/45] feat: remove dep on cwd for workers (#20012) --- .../manifest.json | 1 - server/src/main.ts | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) delete mode 100644 mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json diff --git a/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json b/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json deleted file mode 100644 index 7391713b6f..0000000000 --- a/mobile/ios/build/XCBuildData/a34f3d77f077776687d3b444cba8f1c4.xcbuilddata/manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"client":{"name":"basic","version":0,"file-system":"device-agnostic","perform-ownership-analysis":"no"},"targets":{"":[""]},"commands":{"":{"tool":"phony","inputs":[""],"outputs":[""]},"P0:::Gate WorkspaceHeaderMapVFSFilesWritten":{"tool":"phony","inputs":[],"outputs":[""]}}} \ No newline at end of file diff --git a/server/src/main.ts b/server/src/main.ts index 591fc156d9..68ea396e7a 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,5 +1,6 @@ import { CommandFactory } from 'nest-commander'; import { ChildProcess, fork } from 'node:child_process'; +import { dirname, join } from 'node:path'; import { Worker } from 'node:worker_threads'; import { ImmichAdminModule } from 'src/app.module'; import { ImmichWorker, LogLevel } from 'src/enum'; @@ -33,14 +34,18 @@ const onExit = (name: string, exitCode: number | null) => { function bootstrapWorker(name: ImmichWorker) { console.log(`Starting ${name} worker`); + // eslint-disable-next-line unicorn/prefer-module + const basePath = dirname(__filename); + const workerFile = join(basePath, 'workers', `${name}.js`); + let worker: Worker | ChildProcess; if (name === ImmichWorker.Api) { - worker = fork(`./dist/workers/${name}.js`, [], { + worker = fork(workerFile, [], { execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)), }); apiProcess = worker; } else { - worker = new Worker(`./dist/workers/${name}.js`); + worker = new Worker(workerFile); } worker.on('error', (error) => onError(name, error)); From f33e1ad94c4fa558f78248ea4a6da0d9840e6aa4 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 18 Jul 2025 11:19:06 -0400 Subject: [PATCH 27/45] feat: relocate scripts, PATH update (#20002) Relocate scripts, and PATH updates --- docker/docker-compose.dev.yml | 4 +- e2e/docker-compose.yml | 1 - server/Dockerfile | 6 +-- {docker/scripts => server/bin}/get-cpus.sh | 0 server/bin/immich-admin | 2 +- server/bin/immich-dev | 8 +++- server/bin/start.sh | 44 ++++++++++++++++++++++ server/start.sh | 29 -------------- web/Dockerfile | 5 ++- web/bin/immich-web | 1 + 10 files changed, 60 insertions(+), 40 deletions(-) rename {docker/scripts => server/bin}/get-cpus.sh (100%) create mode 100755 server/bin/start.sh delete mode 100755 server/start.sh diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 56d1817317..f3d1e83fb2 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -16,7 +16,7 @@ name: immich-dev services: immich-server: container_name: immich_server - command: ['/usr/src/app/bin/immich-dev'] + command: ['immich-dev'] image: immich-server-dev:latest # extends: # file: hwaccel.transcoding.yml @@ -70,7 +70,7 @@ services: # user: 0:0 build: context: ../web - command: ['/usr/src/app/bin/immich-web'] + command: ['immich-web'] env_file: - .env ports: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 6ec5402360..26c3951278 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -3,7 +3,6 @@ name: immich-e2e services: immich-server: container_name: immich-e2e-server - command: ['./start.sh'] image: immich-server:latest build: context: ../ diff --git a/server/Dockerfile b/server/Dockerfile index 0b3b864c95..b95887f18b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -12,7 +12,7 @@ ENV PATH="${PATH}:/usr/src/app/bin" \ IMMICH_ENV=development \ NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_VISIBLE_DEVICES=all -ENTRYPOINT ["tini", "--", "/bin/sh"] +ENTRYPOINT ["tini", "--", "/bin/sh", "-c"] FROM dev AS dev-container-server @@ -110,8 +110,6 @@ COPY --from=prod /usr/src/app/bin ./bin COPY --from=web /usr/src/app/build /build/www COPY server/resources resources COPY server/package.json server/package-lock.json ./ -COPY server/start*.sh ./ -COPY "docker/scripts/get-cpus.sh" ./ RUN npm install -g @immich/cli && npm cache clean --force COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE @@ -134,7 +132,7 @@ ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE VOLUME /usr/src/app/upload EXPOSE 2283 -ENTRYPOINT ["tini", "--", "/bin/bash"] +ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] CMD ["start.sh"] HEALTHCHECK CMD immich-healthcheck diff --git a/docker/scripts/get-cpus.sh b/server/bin/get-cpus.sh similarity index 100% rename from docker/scripts/get-cpus.sh rename to server/bin/get-cpus.sh diff --git a/server/bin/immich-admin b/server/bin/immich-admin index 30fd33a20a..0465a362b8 100755 --- a/server/bin/immich-admin +++ b/server/bin/immich-admin @@ -1,3 +1,3 @@ #!/usr/bin/env sh -/usr/src/app/start.sh immich-admin "$@" +start.sh immich-admin "$@" diff --git a/server/bin/immich-dev b/server/bin/immich-dev index 177455d037..85d75b8b0c 100755 --- a/server/bin/immich-dev +++ b/server/bin/immich-dev @@ -1,3 +1,9 @@ #!/usr/bin/env bash -node /usr/src/app/node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@" +if [ "$IMMICH_ENV" != "development" ]; then + echo "This command can only be run in development environments" + exit 1 +fi + +cd /usr/src/app || exit 1 +node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@" diff --git a/server/bin/start.sh b/server/bin/start.sh new file mode 100755 index 0000000000..7c6069570d --- /dev/null +++ b/server/bin/start.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +echo "Initializing Immich $IMMICH_SOURCE_REF" + +lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" +if [ -f "$lib_path" ]; then + export LD_PRELOAD="$lib_path" +else + echo "skipping libmimalloc - path not found $lib_path" +fi +export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib" +SERVER_HOME=/usr/src/app + +read_file_and_export() { + if [ -n "${!1}" ]; then + content="$(cat "${!1}")" + export "$2"="${content}" + unset "$1" + fi +} +read_file_and_export "DB_URL_FILE" "DB_URL" +read_file_and_export "DB_HOSTNAME_FILE" "DB_HOSTNAME" +read_file_and_export "DB_DATABASE_NAME_FILE" "DB_DATABASE_NAME" +read_file_and_export "DB_USERNAME_FILE" "DB_USERNAME" +read_file_and_export "DB_PASSWORD_FILE" "DB_PASSWORD" +read_file_and_export "REDIS_PASSWORD_FILE" "REDIS_PASSWORD" + +if CPU_CORES="${CPU_CORES:=$(get-cpus.sh 2>/dev/null)}"; then + echo "Detected CPU Cores: $CPU_CORES" + if [ "$CPU_CORES" -gt 4 ]; then + export UV_THREADPOOL_SIZE=$CPU_CORES + fi +else + echo "skipping get-cpus.sh - not found in PATH or failed: using default UV_THREADPOOL_SIZE" +fi + +if [ -f "${SERVER_HOME}/dist/main.js" ]; then + exec node "${SERVER_HOME}/dist/main.js" "$@" +else + echo "Error: ${SERVER_HOME}/dist/main.js not found" + if [ "$IMMICH_ENV" = "development" ]; then + echo "You may need to build the server first." + fi + exit 1 +fi diff --git a/server/start.sh b/server/start.sh deleted file mode 100755 index 1a08d01a75..0000000000 --- a/server/start.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -echo "Initializing Immich $IMMICH_SOURCE_REF" - -lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2" -export LD_PRELOAD="$lib_path" -export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib" - -read_file_and_export() { - if [ -n "${!1}" ]; then - content="$(cat "${!1}")" - export "$2"="${content}" - unset "$1" - fi -} -read_file_and_export "DB_URL_FILE" "DB_URL" -read_file_and_export "DB_HOSTNAME_FILE" "DB_HOSTNAME" -read_file_and_export "DB_DATABASE_NAME_FILE" "DB_DATABASE_NAME" -read_file_and_export "DB_USERNAME_FILE" "DB_USERNAME" -read_file_and_export "DB_PASSWORD_FILE" "DB_PASSWORD" -read_file_and_export "REDIS_PASSWORD_FILE" "REDIS_PASSWORD" - -export CPU_CORES="${CPU_CORES:=$(./get-cpus.sh)}" -echo "Detected CPU Cores: $CPU_CORES" -if [ "$CPU_CORES" -gt 4 ]; then - export UV_THREADPOOL_SIZE=$CPU_CORES -fi - -exec node /usr/src/app/dist/main "$@" diff --git a/web/Dockerfile b/web/Dockerfile index 1c6c4b46bf..c83f783391 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -5,7 +5,8 @@ USER node WORKDIR /usr/src/app COPY --chown=node:node package*.json ./ RUN npm ci -ENV CHOKIDAR_USEPOLLING=true +ENV CHOKIDAR_USEPOLLING=true \ + PATH="${PATH}:/usr/src/app/bin" EXPOSE 24678 EXPOSE 3000 -ENTRYPOINT ["/sbin/tini", "--", "/bin/sh"] +ENTRYPOINT ["/sbin/tini", "--", "/bin/sh", "-c"] diff --git a/web/bin/immich-web b/web/bin/immich-web index ea748863db..8868945ee1 100755 --- a/web/bin/immich-web +++ b/web/bin/immich-web @@ -5,6 +5,7 @@ TYPESCRIPT_SDK=/usr/src/open-api/typescript-sdk npm --prefix "$TYPESCRIPT_SDK" install npm --prefix "$TYPESCRIPT_SDK" run build +cd /usr/src/app || exit 1 COUNT=0 UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}" From 9719965caf7d54b7ee492f9e528b83f949bfff25 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Fri, 18 Jul 2025 11:54:15 -0500 Subject: [PATCH 28/45] fix: invalid android manifest (#20015) fix extra > in android manifest --- mobile/android/app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 3aa72d2876..09276f6d4a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -171,7 +171,6 @@ - > From 635f5de186afef2feb7a8e26c80cda2cb068aefa Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:24:35 +0530 Subject: [PATCH 29/45] chore: change dcm constraint to include 1.30.0 (#20017) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/dcm_global.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/dcm_global.yaml b/mobile/dcm_global.yaml index d2465e64b6..c33846e674 100644 --- a/mobile/dcm_global.yaml +++ b/mobile/dcm_global.yaml @@ -1 +1 @@ -version: '>=1.29.0 <1.30.0' +version: '>=1.29.0 <=1.30.0' From dcfe8d5aded526227514257014d8e5f40d00a921 Mon Sep 17 00:00:00 2001 From: megumin Date: Fri, 18 Jul 2025 17:55:24 +0100 Subject: [PATCH 30/45] fix: send filename when viewing the original file (#20005) * feat: add fileName to downloadOriginal response * test: add fileName to ImmichFileResponse for downloadOriginal * lint: use single quotes for fileName string in test --- server/src/services/asset-media.service.spec.ts | 1 + server/src/services/asset-media.service.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 08b42b6cbf..0585a159ac 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -505,6 +505,7 @@ describe(AssetMediaService.name, () => { await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual( new ImmichFileResponse({ path: '/original/path.jpg', + fileName: 'asset-id.jpg', contentType: 'image/jpeg', cacheControl: CacheControl.PrivateWithCache, }), diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 080774d038..517a1f665f 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -197,6 +197,7 @@ export class AssetMediaService extends BaseService { return new ImmichFileResponse({ path: asset.originalPath, + fileName: asset.originalFileName, contentType: mimeTypes.lookup(asset.originalPath), cacheControl: CacheControl.PrivateWithCache, }); From 5d244c6feca7010a64ac78a1e3dc1364ef989955 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Jul 2025 13:16:22 -0500 Subject: [PATCH 31/45] chore: finish drift locked page (#20013) * feat: overlay mechanism * handle merged asset local id extraction * locked view asset viewer actions * pr feedback --- .../pages/library/locked/pin_auth.page.dart | 20 ++++++-- .../presentation/pages/drift_album.page.dart | 4 ++ .../pages/drift_locked_folder.page.dart | 51 ++++++++++++++++--- .../asset_viewer/asset_viewer.page.dart | 9 ++-- .../asset_viewer/bottom_sheet.widget.dart | 7 ++- .../asset_viewer/top_app_bar.widget.dart | 16 +++++- .../infrastructure/action.provider.dart | 20 +++++--- .../lib/routing/app_navigation_observer.dart | 25 ++++++++- mobile/lib/routing/router.dart | 2 +- mobile/lib/services/action.service.dart | 14 ++++- .../common/mesmerizing_sliver_app_bar.dart | 1 - 11 files changed, 143 insertions(+), 26 deletions(-) diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart index cca0e3b7ac..9bfd96ed74 100644 --- a/mobile/lib/pages/library/locked/pin_auth.page.dart +++ b/mobile/lib/pages/library/locked/pin_auth.page.dart @@ -1,13 +1,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart' show useState; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/local_auth.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/forms/pin_registration_form.dart'; import 'package:immich_mobile/widgets/forms/pin_verification_form.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; @RoutePage() class PinAuthPage extends HookConsumerWidget { @@ -19,6 +20,7 @@ class PinAuthPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final localAuthState = ref.watch(localAuthProvider); final showPinRegistrationForm = useState(createPinCode); + final isBetaTimeline = Store.isBetaTimelineEnabled; Future registerBiometric(String pinCode) async { final isRegistered = @@ -39,7 +41,11 @@ class PinAuthPage extends HookConsumerWidget { ), ); - context.replaceRoute(const LockedRoute()); + if (isBetaTimeline) { + context.replaceRoute(const DriftLockedFolderRoute()); + } else { + context.replaceRoute(const LockedRoute()); + } } } @@ -93,8 +99,14 @@ class PinAuthPage extends HookConsumerWidget { Center( child: PinVerificationForm( autoFocus: true, - onSuccess: (_) => - context.replaceRoute(const LockedRoute()), + onSuccess: (_) { + if (isBetaTimeline) { + context + .replaceRoute(const DriftLockedFolderRoute()); + } else { + context.replaceRoute(const LockedRoute()); + } + }, ), ), const SizedBox(height: 24), diff --git a/mobile/lib/presentation/pages/drift_album.page.dart b/mobile/lib/presentation/pages/drift_album.page.dart index fbdf1ef116..c7dffbeaef 100644 --- a/mobile/lib/presentation/pages/drift_album.page.dart +++ b/mobile/lib/presentation/pages/drift_album.page.dart @@ -95,9 +95,13 @@ class _DriftAlbumsPageState extends ConsumerState { return RefreshIndicator( onRefresh: onRefresh, + edgeOffset: 100, child: CustomScrollView( slivers: [ ImmichSliverAppBar( + snap: false, + floating: false, + pinned: true, actions: [ IconButton( icon: const Icon( diff --git a/mobile/lib/presentation/pages/drift_locked_folder.page.dart b/mobile/lib/presentation/pages/drift_locked_folder.page.dart index 9b42cdb103..e134b418e9 100644 --- a/mobile/lib/presentation/pages/drift_locked_folder.page.dart +++ b/mobile/lib/presentation/pages/drift_locked_folder.page.dart @@ -4,14 +4,45 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() -class DriftLockedFolderPage extends StatelessWidget { +class DriftLockedFolderPage extends ConsumerStatefulWidget { const DriftLockedFolderPage({super.key}); + @override + ConsumerState createState() => + _DriftLockedFolderPageState(); +} + +class _DriftLockedFolderPageState extends ConsumerState + with WidgetsBindingObserver { + bool _showOverlay = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (mounted) { + setState(() { + _showOverlay = state != AppLifecycleState.resumed; + }); + } + } + @override Widget build(BuildContext context) { return ProviderScope( @@ -30,12 +61,18 @@ class DriftLockedFolderPage extends StatelessWidget { }, ), ], - child: Timeline( - appBar: MesmerizingSliverAppBar( - title: 'locked_folder'.t(context: context), - ), - bottomSheet: const LockedFolderBottomSheet(), - ), + child: _showOverlay + ? const SizedBox() + : PopScope( + onPopInvokedWithResult: (didPop, _) => + didPop ? ref.read(authProvider.notifier).lockPinCode() : null, + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: 'locked_folder'.t(context: context), + ), + bottomSheet: const LockedFolderBottomSheet(), + ), + ), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 50f4a09197..9356c2f43e 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -25,6 +25,7 @@ import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; import 'package:platform/platform.dart'; @@ -638,6 +639,8 @@ class _AssetViewerState extends ConsumerState { }); }); + final isInLockedView = ref.watch(inLockedViewProvider); + // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. // Issue: https://github.com/flutter/flutter/issues/109037 // TODO: Add a custom scrum builder once the fix lands on stable @@ -666,13 +669,13 @@ class _AssetViewerState extends ConsumerState { ), bottomNavigationBar: showingBottomSheet ? const SizedBox.shrink() - : const Column( + : Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AssetStackRow(), - ViewerBottomBar(), + const AssetStackRow(), + if (!isInLockedView) const ViewerBottomBar(), ], ), ), diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index ea6dece942..a8b7c79588 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/utils/bytes_units.dart'; @@ -44,6 +45,8 @@ class AssetDetailBottomSheet extends ConsumerWidget { serverInfoProvider.select((state) => state.serverFeatures.trash), ); + final isInLockedView = ref.watch(inLockedViewProvider); + final actions = [ const ShareActionButton(source: ActionSource.viewer), if (asset.hasRemote) ...[ @@ -63,8 +66,10 @@ class AssetDetailBottomSheet extends ConsumerWidget { ], ]; + final lockedViewActions = []; + return BaseBottomSheet( - actions: actions, + actions: isInLockedView ? lockedViewActions : actions, slivers: const [_AssetDetailBottomSheet()], controller: controller, initialChildSize: initialChildSize, diff --git a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart index c85c4390ae..4cdf9f2287 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/top_app_bar.widget.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_act import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart'; import 'package:immich_mobile/providers/cast.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; @@ -27,6 +28,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { final user = ref.watch(currentUserProvider); final isOwner = asset is RemoteAsset && asset.ownerId == user?.id; + final isInLockedView = ref.watch(inLockedViewProvider); final isShowingSheet = ref .watch(assetViewerProvider.select((state) => state.showingBottomSheet)); @@ -62,6 +64,14 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { const _KebabMenu(), ]; + final lockedViewActions = [ + if (isCasting || (asset.hasRemote && websocketConnected)) + const CastActionButton( + menuItem: true, + ), + const _KebabMenu(), + ]; + return IgnorePointer( ignoring: opacity < 255, child: AnimatedOpacity( @@ -74,7 +84,11 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { iconTheme: const IconThemeData(size: 22, color: Colors.white), actionsIconTheme: const IconThemeData(size: 22, color: Colors.white), shape: const Border(), - actions: isShowingSheet ? null : actions, + actions: isShowingSheet + ? null + : isInLockedView + ? lockedViewActions + : actions, ), ), ); diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index 456b072a39..a0ebf448fc 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -47,7 +47,18 @@ class ActionNotifier extends Notifier { } List _getLocalIdsForSource(ActionSource source) { - return _getIdsForSource(source).toIds().toList(growable: false); + final Set assets = _getAssets(source); + final List localIds = []; + + for (final asset in assets) { + if (asset is LocalAsset) { + localIds.add(asset.id); + } else if (asset is RemoteAsset && asset.localId != null) { + localIds.add(asset.localId!); + } + } + + return localIds; } List _getOwnedRemoteIdsForSource(ActionSource source) { @@ -162,8 +173,9 @@ class ActionNotifier extends Notifier { Future moveToLockFolder(ActionSource source) async { final ids = _getOwnedRemoteIdsForSource(source); + final localIds = _getLocalIdsForSource(source); try { - await _service.moveToLockFolder(ids); + await _service.moveToLockFolder(ids, localIds); return ActionResult(count: ids.length, success: true); } catch (error, stack) { _logger.severe('Failed to move assets to lock folder', error, stack); @@ -329,7 +341,3 @@ extension on Iterable { return whereType().where((a) => a.ownerId == ownerId); } } - -extension on Iterable { - Iterable toIds() => map((e) => e.id); -} diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart index 047e897c8e..98560018ee 100644 --- a/mobile/lib/routing/app_navigation_observer.dart +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -25,7 +25,7 @@ class AppNavigationObserver extends AutoRouterObserver { @override void didPush(Route route, Route? previousRoute) { _handleLockedViewState(route, previousRoute); - + _handleDriftLockedFolderState(route, previousRoute); Future( () => ref.read(currentRouteNameProvider.notifier).state = route.settings.name, @@ -54,4 +54,27 @@ class AppNavigationObserver extends AutoRouterObserver { ); } } + + _handleDriftLockedFolderState(Route route, Route? previousRoute) { + final isInLockedView = ref.read(inLockedViewProvider); + final isFromLockedViewToDetailView = + route.settings.name == AssetViewerRoute.name && + previousRoute?.settings.name == DriftLockedFolderRoute.name; + + final isFromDetailViewToInfoPanelView = route.settings.name == null && + previousRoute?.settings.name == AssetViewerRoute.name && + isInLockedView; + + if (route.settings.name == DriftLockedFolderRoute.name || + isFromLockedViewToDetailView || + isFromDetailViewToInfoPanelView) { + Future( + () => ref.read(inLockedViewProvider.notifier).state = true, + ); + } else { + Future( + () => ref.read(inLockedViewProvider.notifier).state = false, + ); + } + } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 94e3437d66..3a694df816 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -427,7 +427,7 @@ class AppRouter extends RootStackRouter { ), AutoRoute( page: DriftLockedFolderRoute.page, - guards: [_authGuard, _duplicateGuard], + guards: [_authGuard, _lockedGuard, _duplicateGuard], ), AutoRoute( page: DriftVideoRoute.page, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index adefd5da16..b59df5b3dc 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -83,7 +83,10 @@ class ActionService { ); } - Future moveToLockFolder(List remoteIds) async { + Future moveToLockFolder( + List remoteIds, + List localIds, + ) async { await _assetApiRepository.updateVisibility( remoteIds, AssetVisibilityEnum.locked, @@ -92,6 +95,15 @@ class ActionService { remoteIds, AssetVisibility.locked, ); + + // Ask user if they want to delete local copies + if (localIds.isNotEmpty) { + final deletedIds = await _assetMediaRepository.deleteAll(localIds); + + if (deletedIds.isNotEmpty) { + await _localAssetRepository.delete(deletedIds); + } + } } Future removeFromLockFolder(List remoteIds) async { diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index eace57fe5c..eecc099a9e 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -22,7 +22,6 @@ class MesmerizingSliverAppBar extends ConsumerStatefulWidget { final String title; final IconData icon; - @override ConsumerState createState() => _MesmerizingSliverAppBarState(); From 9e94f52b057005e33cd16927e1f4f344ce1db009 Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 18 Jul 2025 17:56:26 -0400 Subject: [PATCH 32/45] chore: dockerfile layout changes (#19673) Dockerfile layout changes Fix up web path feat: update server env vars for layout --- .dockerignore | 32 ++++++------- .gitignore | 1 + docker/docker-compose.dev.yml | 17 +++---- server/Dockerfile | 88 +++++++++++++++++------------------ server/bin/immich-dev | 4 +- server/bin/start.sh | 2 +- web/Dockerfile | 13 ++++-- web/bin/immich-web | 6 +-- 8 files changed, 85 insertions(+), 78 deletions(-) diff --git a/.dockerignore b/.dockerignore index d152800d1b..f7efb5c56e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,41 +1,41 @@ .vscode/ .github/ .git/ +.env* +*.log +*.tmp +*.temp + +**/Dockerfile +**/node_modules/ +**/.pnpm-store/ +**/dist/ +**/coverage/ +**/build/ design/ docker/ -Dockerfile !docker/scripts + docs/ !docs/package.json !docs/package-lock.json + e2e/ !e2e/package.json !e2e/package-lock.json + fastlane/ machine-learning/ misc/ mobile/ -cli/coverage/ -cli/dist/ -cli/node_modules/ -cli/Dockerfile - open-api/typescript-sdk/build/ -open-api/typescript-sdk/node_modules/ +!open-api/typescript-sdk/package.json +!open-api/typescript-sdk/package-lock.json -server/coverage/ -server/node_modules/ server/upload/ server/src/queries -server/dist/ server/www/ -server/Dockerfile -web/node_modules/ -web/coverage/ web/.svelte-kit -web/build/ -web/.env -web/Dockerfile diff --git a/.gitignore b/.gitignore index b4ebd04841..af85d96c02 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ mobile/android/fastlane/report.xml mobile/ios/fastlane/report.xml vite.config.js.timestamp-* +.pnpm-store diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index f3d1e83fb2..32ff115102 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -27,11 +27,11 @@ services: target: dev restart: unless-stopped volumes: - - ../server:/usr/src/app - - ../open-api:/usr/src/open-api + - ../server:/usr/src/app/server + - ../open-api:/usr/src/app/open-api - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload - ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload/upload - - /usr/src/app/node_modules + - /usr/src/app/server/node_modules - /etc/localtime:/etc/localtime:ro env_file: - .env @@ -69,7 +69,8 @@ services: # Needed for rootless docker setup, see https://github.com/moby/moby/issues/45919 # user: 0:0 build: - context: ../web + context: ../ + dockerfile: web/Dockerfile command: ['immich-web'] env_file: - .env @@ -77,11 +78,11 @@ services: - 3000:3000 - 24678:24678 volumes: - - ../web:/usr/src/app - - ../i18n:/usr/src/i18n - - ../open-api/:/usr/src/open-api/ + - ../web:/usr/src/app/web + - ../i18n:/usr/src/app/i18n + - ../open-api/:/usr/src/app/open-api/ # - ../../ui:/usr/ui - - /usr/src/app/node_modules + - /usr/src/app/web/node_modules ulimits: nofile: soft: 1048576 diff --git a/server/Dockerfile b/server/Dockerfile index b95887f18b..e082d0e69e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -2,25 +2,26 @@ FROM ghcr.io/immich-app/base-server-dev:202507162011@sha256:85d4230c2208646bd6c528db41b2213d780b11b7a311397ca6a2aaba7cf697c8 AS dev WORKDIR /usr/src/app -COPY server/package.json server/package-lock.json ./ +COPY ./server/package* ./server/ +WORKDIR /usr/src/app/server RUN npm ci && \ - # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need - # they're marked as optional dependencies, so we need to copy them manually after pruning - rm -rf node_modules/@img/sharp-libvips* && \ - rm -rf node_modules/@img/sharp-linuxmusl-x64 -ENV PATH="${PATH}:/usr/src/app/bin" \ - IMMICH_ENV=development \ - NVIDIA_DRIVER_CAPABILITIES=all \ - NVIDIA_VISIBLE_DEVICES=all -ENTRYPOINT ["tini", "--", "/bin/sh", "-c"] + # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need + # they're marked as optional dependencies, so we need to copy them manually after pruning + rm -rf node_modules/@img/sharp-libvips* && \ + rm -rf node_modules/@img/sharp-linuxmusl-x64 +ENV PATH="${PATH}:/usr/src/app/server/bin" \ + IMMICH_ENV=development \ + NVIDIA_DRIVER_CAPABILITIES=all \ + NVIDIA_VISIBLE_DEVICES=all +ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] FROM dev AS dev-container-server RUN rm -rf /usr/src/app RUN apt-get update && \ - apt-get install sudo inetutils-ping openjdk-11-jre-headless \ - vim nano \ - -y --no-install-recommends --fix-missing + apt-get install sudo inetutils-ping openjdk-11-jre-headless \ + vim nano \ + -y --no-install-recommends --fix-missing RUN usermod -aG sudo node RUN echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers @@ -38,14 +39,14 @@ FROM dev-container-server AS dev-container-mobile USER root # Enable multiarch for arm64 if necessary RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \ - dpkg --add-architecture amd64 && \ - apt-get update && \ - apt-get install -y --no-install-recommends \ - qemu-user-static \ - libc6:amd64 \ - libstdc++6:amd64 \ - libgcc1:amd64; \ - fi + dpkg --add-architecture amd64 && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + qemu-user-static \ + libc6:amd64 \ + libstdc++6:amd64 \ + libgcc1:amd64; \ + fi # Flutter SDK # https://flutter.dev/docs/development/tools/sdk/releases?tab=linux @@ -77,43 +78,42 @@ FROM dev AS prod COPY server . RUN npm run build RUN npm prune --omit=dev --omit=optional -COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img -COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl +COPY --from=dev /usr/src/app/server/node_modules/@img ./node_modules/@img +COPY --from=dev /usr/src/app/server/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl # web build FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS web -WORKDIR /usr/src/open-api/typescript-sdk -COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ -RUN npm ci -COPY open-api/typescript-sdk/ ./ -RUN npm run build - WORKDIR /usr/src/app -COPY web/package*.json web/svelte.config.js ./ -RUN npm ci -COPY web ./ -COPY i18n ../i18n -RUN npm run build +COPY ./web ./web/ +COPY ./i18n ./i18n/ +COPY ./open-api/typescript-sdk ./open-api/typescript-sdk/ +WORKDIR /usr/src/app/open-api/typescript-sdk +RUN npm ci && npm run build + +WORKDIR /usr/src/app/web +RUN npm ci && npm run build # prod build FROM ghcr.io/immich-app/base-server-prod:202507162011@sha256:636f3ddb6106628ef851d51c23f3fa2c6e4829390cc315b27b38c288c82b23a7 WORKDIR /usr/src/app ENV NODE_ENV=production \ - NVIDIA_DRIVER_CAPABILITIES=all \ - NVIDIA_VISIBLE_DEVICES=all -COPY --from=prod /usr/src/app/node_modules ./node_modules -COPY --from=prod /usr/src/app/dist ./dist -COPY --from=prod /usr/src/app/bin ./bin -COPY --from=web /usr/src/app/build /build/www -COPY server/resources resources -COPY server/package.json server/package-lock.json ./ -RUN npm install -g @immich/cli && npm cache clean --force + NVIDIA_DRIVER_CAPABILITIES=all \ + NVIDIA_VISIBLE_DEVICES=all + +COPY --from=prod /usr/src/app/server/node_modules ./server/node_modules +COPY --from=prod /usr/src/app/server/dist ./server/dist +COPY --from=prod /usr/src/app/server/bin ./server/bin +COPY --from=web /usr/src/app/web/build /build/www +COPY ./server/resources ./server/resources +COPY ./server/package.json server/package-lock.json ./ COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE -ENV PATH="${PATH}:/usr/src/app/bin" + +RUN npm install -g @immich/cli && npm cache clean --force +ENV PATH="${PATH}:/usr/src/app/server/bin" ARG BUILD_ID ARG BUILD_IMAGE diff --git a/server/bin/immich-dev b/server/bin/immich-dev index 85d75b8b0c..533c10ef9d 100755 --- a/server/bin/immich-dev +++ b/server/bin/immich-dev @@ -5,5 +5,5 @@ if [ "$IMMICH_ENV" != "development" ]; then exit 1 fi -cd /usr/src/app || exit 1 -node ./node_modules/.bin/nest start --debug "0.0.0.0:9230" --watch -- "$@" +cd /usr/src/app/server || exit 1 +npm exec nest start --debug "0.0.0.0:9230" --watch -- "$@" diff --git a/server/bin/start.sh b/server/bin/start.sh index 7c6069570d..2b4351a6bc 100755 --- a/server/bin/start.sh +++ b/server/bin/start.sh @@ -8,7 +8,7 @@ else echo "skipping libmimalloc - path not found $lib_path" fi export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib" -SERVER_HOME=/usr/src/app +SERVER_HOME=/usr/src/app/server read_file_and_export() { if [ -n "${!1}" ]; then diff --git a/web/Dockerfile b/web/Dockerfile index c83f783391..3c119fdd4d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,12 +1,17 @@ FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e -RUN apk add --no-cache tini +RUN apk add --no-cache tini bash + USER node WORKDIR /usr/src/app -COPY --chown=node:node package*.json ./ + +COPY --chown=node:node ./web/package* ./web/ + +WORKDIR /usr/src/app/web RUN npm ci + ENV CHOKIDAR_USEPOLLING=true \ - PATH="${PATH}:/usr/src/app/bin" + PATH="${PATH}:/usr/src/app/web/bin" EXPOSE 24678 EXPOSE 3000 -ENTRYPOINT ["/sbin/tini", "--", "/bin/sh", "-c"] +ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] diff --git a/web/bin/immich-web b/web/bin/immich-web index 8868945ee1..d2739cf6c3 100755 --- a/web/bin/immich-web +++ b/web/bin/immich-web @@ -1,11 +1,11 @@ #!/usr/bin/env sh -TYPESCRIPT_SDK=/usr/src/open-api/typescript-sdk +TYPESCRIPT_SDK=/usr/src/app/open-api/typescript-sdk npm --prefix "$TYPESCRIPT_SDK" install npm --prefix "$TYPESCRIPT_SDK" run build -cd /usr/src/app || exit 1 +cd /usr/src/app/web || exit 1 COUNT=0 UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}" @@ -19,4 +19,4 @@ done echo "Connected to $UPSTREAM" -node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000 +npx vite dev --host 0.0.0.0 --port 3000 From f929dc0816e1167cd15f0b8e2231e3e44113557a Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Fri, 18 Jul 2025 19:07:49 -0400 Subject: [PATCH 33/45] fix: devcontainer layout (#20021) --- .devcontainer/mobile/container-compose-overrides.yml | 4 ++-- .devcontainer/server/container-compose-overrides.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.devcontainer/mobile/container-compose-overrides.yml b/.devcontainer/mobile/container-compose-overrides.yml index 62a97a01eb..0543f42317 100644 --- a/.devcontainer/mobile/container-compose-overrides.yml +++ b/.devcontainer/mobile/container-compose-overrides.yml @@ -11,8 +11,8 @@ services: - open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules - server_node_modules:/workspaces/immich/server/node_modules - web_node_modules:/workspaces/immich/web/node_modules - - ${UPLOAD_LOCATION}/photos:/workspaces/immich/server/upload - - ${UPLOAD_LOCATION}/photos/upload:/workspaces/immich/server/upload/upload + - ${UPLOAD_LOCATION}/photos:/usr/src/app/upload + - ${UPLOAD_LOCATION}/photos/upload:/usr/src/app/upload - /etc/localtime:/etc/localtime:ro database: diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index d7efc92cb1..24ac9734b1 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -13,8 +13,8 @@ services: - open_api_node_modules:/workspaces/immich/open-api/typescript-sdk/node_modules - server_node_modules:/workspaces/immich/server/node_modules - web_node_modules:/workspaces/immich/web/node_modules - - ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/workspaces/immich/server/upload - - ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/workspaces/immich/server/upload/upload + - ${UPLOAD_LOCATION:-upload1-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/usr/src/app/upload + - ${UPLOAD_LOCATION:-upload2-devcontainer-volume}${UPLOAD_LOCATION:+/photos/upload}:/usr/src/app/upload/upload - /etc/localtime:/etc/localtime:ro immich-web: From fafb88d31c357d180052143773926d03d3e2256b Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 18 Jul 2025 23:58:53 -0500 Subject: [PATCH 34/45] feat(mobile): new upload (#18726) --- mobile/lib/constants/constants.dart | 5 + .../domain/services/local_album.service.dart | 4 + .../repositories/backup.repository.dart | 154 +++++++++ .../repositories/local_album.repository.dart | 28 +- .../repositories/local_asset.repository.dart | 7 + .../repositories/storage.repository.dart | 48 ++- mobile/lib/main.dart | 23 +- .../upload/share_intent_attachment.model.dart | 2 +- .../lib/pages/backup/drift_backup.page.dart | 294 +++++++++++++++++ .../drift_backup_album_selection.page.dart | 307 ++++++++++++++++++ .../pages/common/change_experience.page.dart | 6 + .../pages/share_intent/share_intent.page.dart | 2 +- .../pages/dev/feat_in_development.page.dart | 27 +- .../providers/app_life_cycle.provider.dart | 3 + .../share_intent_upload.provider.dart | 36 +- .../backup/backup_album.provider.dart | 62 ++++ .../backup/drift_backup.provider.dart | 194 +++++++++++ mobile/lib/providers/websocket.provider.dart | 42 ++- .../lib/repositories/upload.repository.dart | 25 +- mobile/lib/routing/router.dart | 10 + mobile/lib/routing/router.gr.dart | 32 ++ mobile/lib/services/drift_backup.service.dart | 286 ++++++++++++++++ mobile/lib/services/upload.service.dart | 83 +++-- mobile/lib/utils/database.utils.dart | 31 ++ mobile/lib/utils/upload.dart | 1 - .../backup/drift_album_info_list_tile.dart | 121 +++++++ .../widgets/common/immich_sliver_app_bar.dart | 2 +- 27 files changed, 1733 insertions(+), 102 deletions(-) create mode 100644 mobile/lib/infrastructure/repositories/backup.repository.dart create mode 100644 mobile/lib/pages/backup/drift_backup.page.dart create mode 100644 mobile/lib/pages/backup/drift_backup_album_selection.page.dart create mode 100644 mobile/lib/providers/backup/backup_album.provider.dart create mode 100644 mobile/lib/providers/backup/drift_backup.provider.dart create mode 100644 mobile/lib/services/drift_backup.service.dart create mode 100644 mobile/lib/utils/database.utils.dart delete mode 100644 mobile/lib/utils/upload.dart create mode 100644 mobile/lib/widgets/backup/drift_album_info_list_tile.dart diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index c37498ea3e..37a3eec073 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -16,6 +16,11 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB // Secure storage keys const String kSecuredPinCode = "secured_pin_code"; +// background_downloader task groups +const String kManualUploadGroup = 'manual_upload_group'; +const String kBackupGroup = 'backup_group'; +const String kBackupLivePhotoGroup = 'backup_live_photo_group'; + // Timeline constants const int kTimelineNoneSegmentSize = 120; const int kTimelineAssetLoadBatchSize = 256; diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index 9af12ce595..7ec9231196 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -14,4 +14,8 @@ class LocalAlbumService { Future getThumbnail(String albumId) { return _repository.getThumbnail(albumId); } + + Future update(LocalAlbum album) { + return _repository.upsert(album); + } } diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart new file mode 100644 index 0000000000..4ce3b07e8b --- /dev/null +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -0,0 +1,154 @@ +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import "package:immich_mobile/utils/database.utils.dart"; + +final backupRepositoryProvider = Provider( + (ref) => DriftBackupRepository(ref.watch(driftProvider)), +); + +class DriftBackupRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftBackupRepository(this._db) : super(_db); + + _getExcludedSubquery() { + return _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.excluded), + ); + } + + Future getTotalCount() async { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected) & + _db.localAlbumAssetEntity.assetId + .isNotInQuery(_getExcludedSubquery()), + ); + + return query.get().then((rows) => rows.length); + } + + Future getRemainderCount() async { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns( + [_db.localAlbumAssetEntity.assetId], + ) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + useColumns: false, + ), + leftOuterJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum + .equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected) & + _db.remoteAssetEntity.id.isNull() & + _db.localAlbumAssetEntity.assetId + .isNotInQuery(_getExcludedSubquery()), + ); + + return query.get().then((rows) => rows.length); + } + + Future getBackupCount() async { + final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) + ..addColumns( + [_db.localAlbumAssetEntity.assetId], + ) + ..join([ + innerJoin( + _db.localAlbumEntity, + _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), + useColumns: false, + ), + innerJoin( + _db.localAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + useColumns: false, + ), + innerJoin( + _db.remoteAssetEntity, + _db.localAssetEntity.checksum + .equalsExp(_db.remoteAssetEntity.checksum), + useColumns: false, + ), + ]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected) & + _db.remoteAssetEntity.id.isNotNull() & + _db.localAlbumAssetEntity.assetId + .isNotInQuery(_getExcludedSubquery()), + ); + + return query.get().then((rows) => rows.length); + } + + Future> getCandidates() async { + final selectedAlbumIds = _db.localAlbumEntity.selectOnly(distinct: true) + ..addColumns([_db.localAlbumEntity.id]) + ..where( + _db.localAlbumEntity.backupSelection + .equalsValue(BackupSelection.selected), + ); + + final query = _db.localAssetEntity.select() + ..where( + (lae) => + existsQuery( + _db.localAlbumAssetEntity.selectOnly() + ..addColumns([_db.localAlbumAssetEntity.assetId]) + ..where( + _db.localAlbumAssetEntity.albumId + .isInQuery(selectedAlbumIds) & + _db.localAlbumAssetEntity.assetId.equalsExp(lae.id), + ), + ) & + notExistsQuery( + _db.remoteAssetEntity.selectOnly() + ..addColumns([_db.remoteAssetEntity.checksum]) + ..where( + _db.remoteAssetEntity.checksum.equalsExp(lae.checksum) & + lae.checksum.isNotNull(), + ), + ) & + lae.id.isNotInQuery(_getExcludedSubquery()), + ); + + return query.map((localAsset) => localAsset.toDto()).get(); + } +} diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index 44ebe7f7ca..ba9dfd979d 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.d import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/utils/database.utils.dart'; import 'package:platform/platform.dart'; enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum } @@ -381,30 +382,3 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { return results.isNotEmpty ? results.first : null; } } - -extension on LocalAlbumEntityData { - LocalAlbum toDto({int assetCount = 0}) { - return LocalAlbum( - id: id, - name: name, - updatedAt: updatedAt, - assetCount: assetCount, - backupSelection: backupSelection, - ); - } -} - -extension on LocalAssetEntityData { - LocalAsset toDto() { - return LocalAsset( - id: id, - name: name, - checksum: checksum, - type: type, - createdAt: createdAt, - updatedAt: updatedAt, - durationInSeconds: durationInSeconds, - isFavorite: isFavorite, - ); - } -} diff --git a/mobile/lib/infrastructure/repositories/local_asset.repository.dart b/mobile/lib/infrastructure/repositories/local_asset.repository.dart index 31a11f7047..8d21c858a2 100644 --- a/mobile/lib/infrastructure/repositories/local_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_asset.repository.dart @@ -56,4 +56,11 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository { } }); } + + Future getById(String id) { + final query = _db.localAssetEntity.select() + ..where((lae) => lae.id.equals(id)); + + return query.map((row) => row.toDto()).getSingleOrNull(); + } } diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 5b511709cd..0cf4f20ba8 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -7,8 +8,9 @@ class StorageRepository { const StorageRepository(); Future getFileForAsset(String assetId) async { - final log = Logger('StorageRepository'); File? file; + final log = Logger('StorageRepository'); + try { final entity = await AssetEntity.fromId(assetId); file = await entity?.originFile; @@ -20,4 +22,48 @@ class StorageRepository { } return file; } + + Future getMotionFileForAsset(LocalAsset asset) async { + File? file; + final log = Logger('StorageRepository'); + + try { + final entity = await AssetEntity.fromId(asset.id); + file = await entity?.originFileWithSubtype; + if (file == null) { + log.warning( + "Cannot get motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + ); + } + } catch (error, stackTrace) { + log.warning( + "Error getting motion file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + error, + stackTrace, + ); + } + return file; + } + + Future getAssetEntityForAsset(LocalAsset asset) async { + final log = Logger('StorageRepository'); + + AssetEntity? entity; + + try { + entity = await AssetEntity.fromId(asset.id); + if (entity == null) { + log.warning( + "Cannot get AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + ); + } + } catch (error, stackTrace) { + log.warning( + "Error getting AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", + error, + stackTrace, + ); + } + return entity; + } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index f67767f767..acadf4c887 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -93,6 +93,13 @@ Future initApp() async { initializeTimeZones(); + // Initialize the file downloader + + await FileDownloader().configure( + // maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3 + globalConfig: (Config.holdingQueue, (5, 2, 3)), + ); + await FileDownloader().trackTasksInGroup( downloadGroupLivePhoto, markDownloadedComplete: false, @@ -171,7 +178,21 @@ class ImmichAppState extends ConsumerState } void _configureFileDownloaderNotifications() { - FileDownloader().configureNotification( + FileDownloader().configureNotificationForGroup( + downloadGroupImage, + running: TaskNotification( + 'downloading_media'.tr(), + '${'file_name'.tr()}: {filename}', + ), + complete: TaskNotification( + 'download_finished'.tr(), + '${'file_name'.tr()}: {filename}', + ), + progressBar: true, + ); + + FileDownloader().configureNotificationForGroup( + downloadGroupVideo, running: TaskNotification( 'downloading_media'.tr(), '${'file_name'.tr()}: {filename}', diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index 1bdb5b6b48..7e57cf94d2 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -17,7 +17,7 @@ enum UploadStatus { notFound, failed, canceled, - waitingtoRetry, + waitingToRetry, paused, } diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart new file mode 100644 index 0000000000..34649ca42c --- /dev/null +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -0,0 +1,294 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; + +@RoutePage() +class DriftBackupPage extends HookConsumerWidget { + const DriftBackupPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useEffect( + () { + ref.read(driftBackupProvider.notifier).getBackupStatus(); + return null; + }, + [], + ); + + Widget buildControlButtons() { + return Padding( + padding: const EdgeInsets.only( + top: 24, + ), + child: Column( + children: [ + ElevatedButton( + onPressed: () => ref.read(driftBackupProvider.notifier).backup(), + child: const Text( + "backup_controller_page_start_backup", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + OutlinedButton( + onPressed: () => ref.read(driftBackupProvider.notifier).cancel(), + child: const Text( + "cancel", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + OutlinedButton( + onPressed: () => + ref.read(driftBackupProvider.notifier).getDataInfo(), + child: const Text( + "Get database info", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ], + ), + ); + } + + return Scaffold( + appBar: AppBar( + elevation: 0, + title: const Text( + "Backup (Experimental)", + ), + leading: IconButton( + onPressed: () { + ref.watch(websocketProvider.notifier).listenUploadEvent(); + context.maybePop(true); + }, + splashRadius: 24, + icon: const Icon( + Icons.arrow_back_ios_rounded, + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: IconButton( + onPressed: () => context.pushRoute(const BackupOptionsRoute()), + splashRadius: 24, + icon: const Icon( + Icons.settings_outlined, + ), + ), + ), + ], + ), + body: Stack( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16, + bottom: 32, + ), + child: ListView( + children: [ + const SizedBox(height: 8), + const _BackupAlbumSelectionCard(), + const _TotalCard(), + const _BackupCard(), + const _RemainderCard(), + const Divider(), + buildControlButtons(), + ], + ), + ), + ], + ), + ); + } +} + +class _BackupAlbumSelectionCard extends ConsumerWidget { + const _BackupAlbumSelectionCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + Widget buildSelectedAlbumName() { + String text = "backup_controller_page_backup_selected".tr(); + final albums = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.selected, + ) + .toList(); + + if (albums.isNotEmpty) { + for (var album in albums) { + if (album.name == "Recent" || album.name == "Recents") { + text += "${album.name} (${'all'.tr()}), "; + } else { + text += "${album.name}, "; + } + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ), + ); + } else { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + "backup_controller_page_none_selected".tr(), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ), + ); + } + } + + Widget buildExcludedAlbumName() { + String text = "backup_controller_page_excluded".tr(); + final albums = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.excluded, + ) + .toList(); + + if (albums.isNotEmpty) { + for (var album in albums) { + text += "${album.name}, "; + } + + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + text.trim().substring(0, text.length - 2), + style: context.textTheme.labelLarge?.copyWith( + color: Colors.red[300], + ), + ), + ); + } else { + return const SizedBox(); + } + } + + return Card( + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(20)), + side: BorderSide( + color: context.colorScheme.outlineVariant, + width: 1, + ), + ), + elevation: 0, + borderOnForeground: false, + child: ListTile( + minVerticalPadding: 18, + title: Text( + "backup_controller_page_albums", + style: context.textTheme.titleMedium, + ).tr(), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "backup_controller_page_to_backup", + style: context.textTheme.bodyMedium?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ).tr(), + buildSelectedAlbumName(), + buildExcludedAlbumName(), + ], + ), + ), + trailing: ElevatedButton( + onPressed: () async { + await context.pushRoute(const DriftBackupAlbumSelectionRoute()); + ref.read(driftBackupProvider.notifier).getBackupStatus(); + }, + child: const Text( + "select", + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + ), + ), + ); + } +} + +class _TotalCard extends ConsumerWidget { + const _TotalCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final totalCount = + ref.watch(driftBackupProvider.select((p) => p.totalCount)); + + return BackupInfoCard( + title: "total".tr(), + subtitle: "backup_controller_page_total_sub".tr(), + info: totalCount.toString(), + ); + } +} + +class _BackupCard extends ConsumerWidget { + const _BackupCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backupCount = + ref.watch(driftBackupProvider.select((p) => p.backupCount)); + + return BackupInfoCard( + title: "backup_controller_page_backup".tr(), + subtitle: "backup_controller_page_backup_sub".tr(), + info: backupCount.toString(), + ); + } +} + +class _RemainderCard extends ConsumerWidget { + const _RemainderCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final remainderCount = + ref.watch(driftBackupProvider.select((p) => p.remainderCount)); + return BackupInfoCard( + title: "backup_controller_page_remainder".tr(), + subtitle: "backup_controller_page_remainder_sub".tr(), + info: remainderCount.toString(), + ); + } +} diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart new file mode 100644 index 0000000000..2e7a2d4c2d --- /dev/null +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -0,0 +1,307 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; + +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; +import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; + +@RoutePage() +class DriftBackupAlbumSelectionPage extends HookConsumerWidget { + const DriftBackupAlbumSelectionPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(backupAlbumProvider); + + final selectedBackupAlbums = albums + .where((album) => album.backupSelection == BackupSelection.selected) + .toList(); + final excludedBackupAlbums = albums + .where((album) => album.backupSelection == BackupSelection.excluded) + .toList(); + final enableSyncUploadAlbum = + useAppSettingsState(AppSettingsEnum.syncAlbums); + final isDarkTheme = context.isDarkTheme; + + useEffect( + () { + ref.watch(backupProvider.notifier).getBackupInfo(); + ref.watch(backupAlbumProvider.notifier).getAll(); + return null; + }, + [], + ); + + buildAlbumSelectionList() { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + ((context, index) { + return DriftAlbumInfoListTile( + album: albums[index], + ); + }), + childCount: albums.length, + ), + ), + ); + } + + buildAlbumSelectionGrid() { + if (albums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 300, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: albums.length, + itemBuilder: ((context, index) { + return DriftAlbumInfoListTile( + album: albums[index], + ); + }), + ), + ); + } + + buildSelectedAlbumNameChip() { + return selectedBackupAlbums.map((album) { + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + onTap: removeSelection, + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: isDarkTheme ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: context.primaryColor, + deleteIconColor: isDarkTheme ? Colors.black : Colors.white, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ); + }).toSet(); + } + + buildExcludedAlbumNameChip() { + return excludedBackupAlbums.map((album) { + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return GestureDetector( + onTap: removeSelection, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: context.scaffoldBackgroundColor, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.red[300], + deleteIconColor: context.scaffoldBackgroundColor, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ); + }).toSet(); + } + + handleSyncAlbumToggle(bool isEnable) async { + if (isEnable) { + await ref.read(albumProvider.notifier).refreshRemoteAlbums(); + for (final album in selectedBackupAlbums) { + await ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.maybePop(), + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + title: const Text( + "backup_album_selection_page_select_albums", + ).tr(), + elevation: 0, + ), + body: SafeArea( + child: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, + ), + child: Text( + "backup_album_selection_page_selection_info", + style: context.textTheme.titleSmall, + ).tr(), + ), + // Selected Album Chips + + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + children: [ + ...buildSelectedAlbumNameChip(), + ...buildExcludedAlbumNameChip(), + ], + ), + ), + + SettingsSwitchListTile( + valueNotifier: enableSyncUploadAlbum, + title: "sync_albums".tr(), + subtitle: "sync_upload_album_setting_subtitle".tr(), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + + ListTile( + title: Text( + "backup_album_selection_page_albums_device".tr( + namedArgs: { + 'count': ref + .watch(backupProvider) + .availableAlbums + .length + .toString(), + }, + ), + style: context.textTheme.titleSmall, + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "backup_album_selection_page_albums_tap", + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + ), + ).tr(), + ), + trailing: IconButton( + splashRadius: 16, + icon: Icon( + Icons.info, + size: 20, + color: context.primaryColor, + ), + onPressed: () { + // show the dialog + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + ), + elevation: 5, + title: Text( + 'backup_album_selection_page_selection_info', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + ).tr(), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text( + 'backup_album_selection_page_assets_scatter', + style: TextStyle( + fontSize: 14, + ), + ).tr(), + ], + ), + ), + ); + }, + ); + }, + ), + ), + + // buildSearchBar(), + ], + ), + ), + SliverLayoutBuilder( + builder: (context, constraints) { + if (constraints.crossAxisExtent > 600) { + return buildAlbumSelectionGrid(); + } else { + return buildAlbumSelectionList(); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 74c3116962..5d298edb42 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -8,6 +8,7 @@ import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/utils/migration.dart'; import 'package:permission_handler/permission_handler.dart'; @@ -42,6 +43,9 @@ class _ChangeExperiencePageState extends ConsumerState { albumNotifier.dispose(); } + ref.read(websocketProvider.notifier).stopListenToOldEvents(); + ref.read(websocketProvider.notifier).startListeningToBetaEvents(); + final permission = await ref .read(galleryPermissionNotifier.notifier) .requestGalleryPermission(); @@ -55,6 +59,8 @@ class _ChangeExperiencePageState extends ConsumerState { } } else { await ref.read(backgroundSyncProvider).cancel(); + ref.read(websocketProvider.notifier).stopListeningToBetaEvents(); + ref.read(websocketProvider.notifier).startListeningToOldEvents(); } if (mounted) { diff --git a/mobile/lib/pages/share_intent/share_intent.page.dart b/mobile/lib/pages/share_intent/share_intent.page.dart index 11c114e4a6..299ffe5497 100644 --- a/mobile/lib/pages/share_intent/share_intent.page.dart +++ b/mobile/lib/pages/share_intent/share_intent.page.dart @@ -268,7 +268,7 @@ class UploadStatusIcon extends StatelessWidget { color: Colors.red, semanticLabel: 'canceled'.tr(), ), - UploadStatus.waitingtoRetry || UploadStatus.paused => Icon( + UploadStatus.waitingToRetry || UploadStatus.paused => Icon( Icons.pause_circle_rounded, color: context.primaryColor, semanticLabel: 'paused'.tr(), diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index e94329acd2..7ee151f94d 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -91,6 +91,10 @@ final _features = [ ), _Feature( name: 'Clear Local Data', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), icon: Icons.delete_forever_rounded, onTap: (_, ref) async { final db = ref.read(driftProvider); @@ -101,6 +105,10 @@ final _features = [ ), _Feature( name: 'Clear Remote Data', + style: const TextStyle( + color: Colors.orange, + fontWeight: FontWeight.bold, + ), icon: Icons.delete_sweep_rounded, onTap: (_, ref) async { final db = ref.read(driftProvider); @@ -117,17 +125,29 @@ final _features = [ ), _Feature( name: 'Local Media Summary', + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), icon: Icons.table_chart_rounded, onTap: (ctx, _) => ctx.pushRoute(const LocalMediaSummaryRoute()), ), _Feature( name: 'Remote Media Summary', + style: const TextStyle( + color: Colors.indigo, + fontWeight: FontWeight.bold, + ), icon: Icons.summarize_rounded, onTap: (ctx, _) => ctx.pushRoute(const RemoteMediaSummaryRoute()), ), _Feature( name: 'Reset Sqlite', icon: Icons.table_view_rounded, + style: const TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), onTap: (_, ref) async { final drift = ref.read(driftProvider); // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member @@ -160,7 +180,10 @@ class FeatInDevPage extends StatelessWidget { final feat = _features[index]; return Consumer( builder: (ctx, ref, _) => ListTile( - title: Text(feat.name), + title: Text( + feat.name, + style: feat.style, + ), trailing: Icon(feat.icon), visualDensity: VisualDensity.compact, onTap: () => unawaited(feat.onTap(ctx, ref)), @@ -183,10 +206,12 @@ class _Feature { required this.name, required this.icon, required this.onTap, + this.style, }); final String name; final IconData icon; + final TextStyle? style; final Future Function(BuildContext, WidgetRef _) onTap; } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 997058d763..5984160241 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -69,6 +69,9 @@ class AppLifeCycleNotifier extends StateNotifier { } await _ref.read(serverInfoProvider.notifier).getServerVersion(); + + // TODO: Need to decide on how we want to handle uploads once the app is resumed + // await FileDownloader().start(); } if (!Store.isBetaTimelineEnabled) { diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index ed2c485b13..3c448b112f 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; @@ -30,7 +29,7 @@ class ShareIntentUploadStateNotifier this._uploadService, this._shareIntentService, ) : super([]) { - _uploadService.onUploadStatus = _uploadStatusCallback; + _uploadService.onUploadStatus = _updateUploadStatus; _uploadService.onTaskProgress = _taskProgressCallback; } @@ -69,8 +68,8 @@ class ShareIntentUploadStateNotifier state = []; } - void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async { - if (status == TaskStatus.canceled) { + void _updateUploadStatus(TaskStatusUpdate task) async { + if (task.status == TaskStatus.canceled) { return; } @@ -83,7 +82,7 @@ class ShareIntentUploadStateNotifier TaskStatus.running => UploadStatus.running, TaskStatus.paused => UploadStatus.paused, TaskStatus.notFound => UploadStatus.notFound, - TaskStatus.waitingToRetry => UploadStatus.waitingtoRetry + TaskStatus.waitingToRetry => UploadStatus.waitingToRetry }; state = [ @@ -95,27 +94,6 @@ class ShareIntentUploadStateNotifier ]; } - void _uploadStatusCallback(TaskStatusUpdate update) { - _updateUploadStatus(update, update.status); - - switch (update.status) { - case TaskStatus.complete: - if (update.responseStatusCode == 200) { - if (kDebugMode) { - debugPrint("[COMPLETE] ${update.task.taskId} - DUPLICATE"); - } - } else { - if (kDebugMode) { - debugPrint("[COMPLETE] ${update.task.taskId}"); - } - } - break; - - default: - break; - } - } - void _taskProgressCallback(TaskProgressUpdate update) { // Ignore if the task is canceled or completed if (update.progress == downloadFailed || @@ -134,10 +112,6 @@ class ShareIntentUploadStateNotifier } Future upload(File file) { - return _uploadService.upload(file); - } - - Future cancelUpload(String id) { - return _uploadService.cancelUpload(id); + return _uploadService.buildUploadTask(file, group: kManualUploadGroup); } } diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart new file mode 100644 index 0000000000..b36d3ac57e --- /dev/null +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -0,0 +1,62 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/services/local_album.service.dart'; +import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; + +final backupAlbumProvider = + StateNotifierProvider>( + (ref) => BackupAlbumNotifier( + ref.watch(localAlbumServiceProvider), + ), +); + +class BackupAlbumNotifier extends StateNotifier> { + BackupAlbumNotifier(this._localAlbumService) : super([]) { + getAll(); + } + + final LocalAlbumService _localAlbumService; + + Future getAll() async { + state = await _localAlbumService.getAll(); + } + + Future selectAlbum(LocalAlbum album) async { + album = album.copyWith(backupSelection: BackupSelection.selected); + await _localAlbumService.update(album); + + state = state + .map( + (currentAlbum) => currentAlbum.id == album.id + ? currentAlbum.copyWith(backupSelection: BackupSelection.selected) + : currentAlbum, + ) + .toList(); + } + + Future deselectAlbum(LocalAlbum album) async { + album = album.copyWith(backupSelection: BackupSelection.none); + await _localAlbumService.update(album); + + state = state + .map( + (currentAlbum) => currentAlbum.id == album.id + ? currentAlbum.copyWith(backupSelection: BackupSelection.none) + : currentAlbum, + ) + .toList(); + } + + Future excludeAlbum(LocalAlbum album) async { + album = album.copyWith(backupSelection: BackupSelection.excluded); + await _localAlbumService.update(album); + + state = state + .map( + (currentAlbum) => currentAlbum.id == album.id + ? currentAlbum.copyWith(backupSelection: BackupSelection.excluded) + : currentAlbum, + ) + .toList(); + } +} diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart new file mode 100644 index 0000000000..2544d208c4 --- /dev/null +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/services/drift_backup.service.dart'; +import 'package:immich_mobile/services/upload.service.dart'; + +class DriftUploadStatus { + final String taskId; + final String filename; + final double progress; + + const DriftUploadStatus({ + required this.taskId, + required this.filename, + required this.progress, + }); + + DriftUploadStatus copyWith({ + String? taskId, + String? filename, + double? progress, + }) { + return DriftUploadStatus( + taskId: taskId ?? this.taskId, + filename: filename ?? this.filename, + progress: progress ?? this.progress, + ); + } + + @override + String toString() => + 'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)'; + + @override + bool operator ==(covariant DriftUploadStatus other) { + if (identical(this, other)) return true; + + return other.taskId == taskId && + other.filename == filename && + other.progress == progress; + } + + @override + int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode; +} + +class DriftBackupState { + final int totalCount; + final int backupCount; + final int remainderCount; + final Map uploadItems; + + const DriftBackupState({ + required this.totalCount, + required this.backupCount, + required this.remainderCount, + required this.uploadItems, + }); + + DriftBackupState copyWith({ + int? totalCount, + int? backupCount, + int? remainderCount, + Map? uploadItems, + }) { + return DriftBackupState( + totalCount: totalCount ?? this.totalCount, + backupCount: backupCount ?? this.backupCount, + remainderCount: remainderCount ?? this.remainderCount, + uploadItems: uploadItems ?? this.uploadItems, + ); + } + + @override + String toString() { + return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)'; + } + + @override + bool operator ==(covariant DriftBackupState other) { + if (identical(this, other)) return true; + final mapEquals = const DeepCollectionEquality().equals; + + return other.totalCount == totalCount && + other.backupCount == backupCount && + other.remainderCount == remainderCount && + mapEquals(other.uploadItems, uploadItems); + } + + @override + int get hashCode { + return totalCount.hashCode ^ + backupCount.hashCode ^ + remainderCount.hashCode ^ + uploadItems.hashCode; + } +} + +final driftBackupProvider = + StateNotifierProvider((ref) { + return ExpBackupNotifier( + ref.watch(driftBackupServiceProvider), + ref.watch(uploadServiceProvider), + ); +}); + +class ExpBackupNotifier extends StateNotifier { + ExpBackupNotifier( + this._backupService, + this._uploadService, + ) : super( + const DriftBackupState( + totalCount: 0, + backupCount: 0, + remainderCount: 0, + uploadItems: {}, + ), + ) { + { + _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); + _uploadService.taskProgressStream.listen(_handleTaskProgressUpdate); + } + } + + final DriftBackupService _backupService; + final UploadService _uploadService; + StreamSubscription? _statusSubscription; + StreamSubscription? _progressSubscription; + + void _handleTaskStatusUpdate(TaskStatusUpdate update) { + switch (update.status) { + case TaskStatus.complete: + state = state.copyWith( + backupCount: state.backupCount + 1, + remainderCount: state.remainderCount - 1, + ); + break; + + default: + break; + } + } + + void _handleTaskProgressUpdate(TaskProgressUpdate update) {} + + Future getBackupStatus() async { + final [totalCount, backupCount, remainderCount] = await Future.wait([ + _backupService.getTotalCount(), + _backupService.getBackupCount(), + _backupService.getRemainderCount(), + ]); + + state = state.copyWith( + totalCount: totalCount, + backupCount: backupCount, + remainderCount: remainderCount, + ); + } + + Future backup() { + return _backupService.backup(); + } + + Future cancel() async { + await _backupService.cancel(); + await getDataInfo(); + } + + Future getDataInfo() async { + final a = await FileDownloader().database.allRecordsWithStatus( + TaskStatus.enqueued, + group: kBackupGroup, + ); + + final b = await FileDownloader().allTasks( + group: kBackupGroup, + ); + + debugPrint( + "Enqueued tasks: ${a.length}, All tasks: ${b.length}", + ); + } + + @override + void dispose() { + _statusSubscription?.cancel(); + _progressSubscription?.cancel(); + super.dispose(); + } +} diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index d9db831776..05fe5a087c 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -176,16 +176,14 @@ class WebsocketNotifier extends StateNotifier { ); }); - socket.on('on_upload_success', _handleOnUploadSuccess); + if (!Store.isBetaTimelineEnabled) { + startListeningToOldEvents(); + } else { + startListeningToBetaEvents(); + } + socket.on('on_config_update', _handleOnConfigUpdate); - socket.on('on_asset_delete', _handleOnAssetDelete); - socket.on('on_asset_trash', _handleOnAssetTrash); - socket.on('on_asset_restore', _handleServerUpdates); - socket.on('on_asset_update', _handleServerUpdates); - socket.on('on_asset_stack_update', _handleServerUpdates); - socket.on('on_asset_hidden', _handleOnAssetHidden); socket.on('on_new_release', _handleReleaseUpdates); - socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } @@ -213,6 +211,34 @@ class WebsocketNotifier extends StateNotifier { state.socket?.off(eventName); } + void stopListenToOldEvents() { + state.socket?.off('on_upload_success'); + state.socket?.off('on_asset_delete'); + state.socket?.off('on_asset_trash'); + state.socket?.off('on_asset_restore'); + state.socket?.off('on_asset_update'); + state.socket?.off('on_asset_stack_update'); + state.socket?.off('on_asset_hidden'); + } + + void startListeningToOldEvents() { + state.socket?.on('on_upload_success', _handleOnUploadSuccess); + state.socket?.on('on_asset_delete', _handleOnAssetDelete); + state.socket?.on('on_asset_trash', _handleOnAssetTrash); + state.socket?.on('on_asset_restore', _handleServerUpdates); + state.socket?.on('on_asset_update', _handleServerUpdates); + state.socket?.on('on_asset_stack_update', _handleServerUpdates); + state.socket?.on('on_asset_hidden', _handleOnAssetHidden); + } + + void stopListeningToBetaEvents() { + state.socket?.off('AssetUploadReadyV1'); + } + + void startListeningToBetaEvents() { + state.socket?.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); + } + void listenUploadEvent() { debugPrint("Start listening to event on_upload_success"); state.socket?.on('on_upload_success', _handleOnUploadSuccess); diff --git a/mobile/lib/repositories/upload.repository.dart b/mobile/lib/repositories/upload.repository.dart index 4f840fa3c6..b98eece656 100644 --- a/mobile/lib/repositories/upload.repository.dart +++ b/mobile/lib/repositories/upload.repository.dart @@ -1,6 +1,6 @@ import 'package:background_downloader/background_downloader.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/utils/upload.dart'; +import 'package:immich_mobile/constants/constants.dart'; final uploadRepositoryProvider = Provider((ref) => UploadRepository()); @@ -11,25 +11,30 @@ class UploadRepository { UploadRepository() { FileDownloader().registerCallbacks( - group: uploadGroup, + group: kBackupGroup, + taskStatusCallback: (update) => onUploadStatus?.call(update), + taskProgressCallback: (update) => onTaskProgress?.call(update), + ); + FileDownloader().registerCallbacks( + group: kBackupLivePhotoGroup, taskStatusCallback: (update) => onUploadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); } - Future upload(UploadTask task) { - return FileDownloader().enqueue(task); + void enqueueAll(List tasks) { + FileDownloader().enqueueAll(tasks); } - Future deleteAllTrackingRecords() { - return FileDownloader().database.deleteAllRecords(); + Future deleteAllTrackingRecords(String group) { + return FileDownloader().database.deleteAllRecords(group: group); } - Future cancel(String id) { - return FileDownloader().cancelTaskWithId(id); + Future cancelAll(String group) { + return FileDownloader().cancelAll(group: group); } - Future deleteRecordsWithIds(List ids) { - return FileDownloader().database.deleteRecordsWithIds(ids); + Future reset(String group) { + return FileDownloader().reset(group: group); } } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 3a694df816..d7dd45dbd9 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -22,6 +22,8 @@ import 'package:immich_mobile/pages/album/album_shared_user_selection.page.dart' import 'package:immich_mobile/pages/album/album_viewer.page.dart'; import 'package:immich_mobile/pages/albums/albums.page.dart'; import 'package:immich_mobile/pages/backup/album_preview.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup_album_selection.page.dart'; +import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; @@ -385,6 +387,14 @@ class AppRouter extends RootStackRouter { page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftBackupRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: DriftBackupAlbumSelectionRoute.page, + guards: [_authGuard, _duplicateGuard], + ), AutoRoute( page: LocalTimelineRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 716d5ef89a..c72dc62765 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -726,6 +726,38 @@ class DriftAssetSelectionTimelineRouteArgs { } } +/// generated route for +/// [DriftBackupAlbumSelectionPage] +class DriftBackupAlbumSelectionRoute extends PageRouteInfo { + const DriftBackupAlbumSelectionRoute({List? children}) + : super(DriftBackupAlbumSelectionRoute.name, initialChildren: children); + + static const String name = 'DriftBackupAlbumSelectionRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftBackupAlbumSelectionPage(); + }, + ); +} + +/// generated route for +/// [DriftBackupPage] +class DriftBackupRoute extends PageRouteInfo { + const DriftBackupRoute({List? children}) + : super(DriftBackupRoute.name, initialChildren: children); + + static const String name = 'DriftBackupRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftBackupPage(); + }, + ); +} + /// generated route for /// [DriftCreateAlbumPage] class DriftCreateAlbumRoute extends PageRouteInfo { diff --git a/mobile/lib/services/drift_backup.service.dart b/mobile/lib/services/drift_backup.service.dart new file mode 100644 index 0000000000..5966aad304 --- /dev/null +++ b/mobile/lib/services/drift_backup.service.dart @@ -0,0 +1,286 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; +import 'package:immich_mobile/services/upload.service.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final driftBackupServiceProvider = Provider( + (ref) => DriftBackupService( + ref.watch(backupRepositoryProvider), + ref.watch(storageRepositoryProvider), + ref.watch(uploadServiceProvider), + ref.watch(localAssetRepository), + ), +); + +class DriftBackupService { + DriftBackupService( + this._backupRepository, + this._storageRepository, + this._uploadService, + this._localAssetRepository, + ) { + _uploadService.taskStatusStream.listen(_handleTaskStatusUpdate); + } + + final DriftBackupRepository _backupRepository; + final StorageRepository _storageRepository; + final DriftLocalAssetRepository _localAssetRepository; + final UploadService _uploadService; + final _log = Logger("DriftBackupService"); + + bool shouldCancel = false; + + Future getTotalCount() { + return _backupRepository.getTotalCount(); + } + + Future getRemainderCount() { + return _backupRepository.getRemainderCount(); + } + + Future getBackupCount() { + return _backupRepository.getBackupCount(); + } + + Future backup() async { + shouldCancel = false; + + final candidates = await _backupRepository.getCandidates(); + if (candidates.isEmpty) { + return; + } + + const batchSize = 100; + int count = 0; + for (int i = 0; i < candidates.length; i += batchSize) { + if (shouldCancel) { + break; + } + + final batch = candidates.skip(i).take(batchSize).toList(); + + List tasks = []; + for (final asset in batch) { + final task = await _getUploadTask(asset); + if (task != null) { + tasks.add(task); + } + } + + if (tasks.isNotEmpty && !shouldCancel) { + count += tasks.length; + _uploadService.enqueueTasks(tasks); + debugPrint( + "Enqueued $count/${candidates.length} tasks for backup", + ); + } + } + } + + void _handleTaskStatusUpdate(TaskStatusUpdate update) { + switch (update.status) { + case TaskStatus.complete: + _handleLivePhoto(update); + break; + + default: + break; + } + } + + Future _handleLivePhoto(TaskStatusUpdate update) async { + try { + if (update.task.metaData.isEmpty || update.task.metaData == '') { + return; + } + + final metadata = UploadTaskMetadata.fromJson(update.task.metaData); + if (!metadata.isLivePhotos) { + return; + } + + if (update.responseBody == null || update.responseBody!.isEmpty) { + return; + } + final response = jsonDecode(update.responseBody!); + + final localAsset = + await _localAssetRepository.getById(metadata.localAssetId); + if (localAsset == null) { + return; + } + + final uploadTask = await _getLivePhotoUploadTask( + localAsset, + response['id'] as String, + ); + + if (uploadTask == null) { + return; + } + + _uploadService.enqueueTasks([uploadTask]); + } catch (error, stackTrace) { + _log.severe("Error handling live photo upload task", error, stackTrace); + debugPrint("Error handling live photo upload task: $error $stackTrace"); + } + } + + Future _getUploadTask(LocalAsset asset) async { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return null; + } + + File? file; + + /// iOS LivePhoto has two files: a photo and a video. + /// They are uploaded separately, with video file being upload first, then returned with the assetId + /// The assetId is then used as a metadata for the photo file upload task. + /// + /// We implement two separate upload groups for this, the normal one for the video file + /// and the higher priority group for the photo file because the video file is already uploaded. + /// + /// The cancel operation will only cancel the video group (normal group), the photo group will not + /// be touched, as the video file is already uploaded. + + if (entity.isLivePhoto) { + file = await _storageRepository.getMotionFileForAsset(asset); + } else { + file = await _storageRepository.getFileForAsset(asset.id); + } + + if (file == null) { + return null; + } + + final originalFileName = entity.isLivePhoto + ? p.setExtension( + asset.name, + p.extension(file.path), + ) + : asset.name; + + String metadata = UploadTaskMetadata( + localAssetId: asset.id, + isLivePhotos: entity.isLivePhoto, + livePhotoVideoId: '', + ).toJson(); + + return _uploadService.buildUploadTask( + file, + originalFileName: originalFileName, + deviceAssetId: asset.id, + metadata: metadata, + group: kBackupGroup, + ); + } + + Future _getLivePhotoUploadTask( + LocalAsset asset, + String livePhotoVideoId, + ) async { + final entity = await _storageRepository.getAssetEntityForAsset(asset); + if (entity == null) { + return null; + } + + final file = await _storageRepository.getFileForAsset(asset.id); + if (file == null) { + return null; + } + + final fields = { + 'livePhotoVideoId': livePhotoVideoId, + }; + + return _uploadService.buildUploadTask( + file, + originalFileName: asset.name, + deviceAssetId: asset.id, + fields: fields, + group: kBackupLivePhotoGroup, + priority: 0, + ); + } + + Future cancel() async { + shouldCancel = true; + await _uploadService.cancelAllForGroup(kBackupGroup); + } +} + +class UploadTaskMetadata { + final String localAssetId; + final bool isLivePhotos; + final String livePhotoVideoId; + + const UploadTaskMetadata({ + required this.localAssetId, + required this.isLivePhotos, + required this.livePhotoVideoId, + }); + + UploadTaskMetadata copyWith({ + String? localAssetId, + bool? isLivePhotos, + String? livePhotoVideoId, + }) { + return UploadTaskMetadata( + localAssetId: localAssetId ?? this.localAssetId, + isLivePhotos: isLivePhotos ?? this.isLivePhotos, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + ); + } + + Map toMap() { + return { + 'localAssetId': localAssetId, + 'isLivePhotos': isLivePhotos, + 'livePhotoVideoId': livePhotoVideoId, + }; + } + + factory UploadTaskMetadata.fromMap(Map map) { + return UploadTaskMetadata( + localAssetId: map['localAssetId'] as String, + isLivePhotos: map['isLivePhotos'] as bool, + livePhotoVideoId: map['livePhotoVideoId'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory UploadTaskMetadata.fromJson(String source) => + UploadTaskMetadata.fromMap(json.decode(source) as Map); + + @override + String toString() => + 'UploadTaskMetadata(localAssetId: $localAssetId, isLivePhotos: $isLivePhotos, livePhotoVideoId: $livePhotoVideoId)'; + + @override + bool operator ==(covariant UploadTaskMetadata other) { + if (identical(this, other)) return true; + + return other.localAssetId == localAssetId && + other.isLivePhotos == isLivePhotos && + other.livePhotoVideoId == livePhotoVideoId; + } + + @override + int get hashCode => + localAssetId.hashCode ^ isLivePhotos.hashCode ^ livePhotoVideoId.hashCode; +} diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 18f90ab844..60aab4a16c 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; @@ -6,22 +7,28 @@ import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/repositories/upload.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; -import 'package:immich_mobile/utils/upload.dart'; import 'package:path/path.dart'; -// import 'package:logging/logging.dart'; -final uploadServiceProvider = Provider( - (ref) => UploadService( - ref.watch(uploadRepositoryProvider), - ), -); +final uploadServiceProvider = Provider((ref) { + final service = UploadService(ref.watch(uploadRepositoryProvider)); + ref.onDispose(service.dispose); + return service; +}); class UploadService { final UploadRepository _uploadRepository; - // final Logger _log = Logger("UploadService"); void Function(TaskStatusUpdate)? onUploadStatus; void Function(TaskProgressUpdate)? onTaskProgress; + final StreamController _taskStatusController = + StreamController.broadcast(); + final StreamController _taskProgressController = + StreamController.broadcast(); + + Stream get taskStatusStream => _taskStatusController.stream; + Stream get taskProgressStream => + _taskProgressController.stream; + UploadService( this._uploadRepository, ) { @@ -31,29 +38,65 @@ class UploadService { void _onTaskProgressCallback(TaskProgressUpdate update) { onTaskProgress?.call(update); + if (!_taskProgressController.isClosed) { + _taskProgressController.add(update); + } } void _onUploadCallback(TaskStatusUpdate update) { onUploadStatus?.call(update); + if (!_taskStatusController.isClosed) { + _taskStatusController.add(update); + } + } + + void dispose() { + _taskStatusController.close(); + _taskProgressController.close(); } Future cancelUpload(String id) { return FileDownloader().cancelTaskWithId(id); } - Future upload(File file) async { - final task = await _buildUploadTask( - hash(file.path).toString(), - file, - ); - - await _uploadRepository.upload(task); + Future cancelAllForGroup(String group) async { + await _uploadRepository.cancelAll(group); + await _uploadRepository.reset(group); + await _uploadRepository.deleteAllTrackingRecords(group); } - Future _buildUploadTask( + void enqueueTasks(List tasks) { + _uploadRepository.enqueueAll(tasks); + } + + Future buildUploadTask( + File file, { + required String group, + Map? fields, + String? originalFileName, + String? deviceAssetId, + String? metadata, + int? priority, + }) async { + return _buildTask( + deviceAssetId ?? hash(file.path).toString(), + file, + fields: fields, + originalFileName: originalFileName, + metadata: metadata, + group: group, + priority: priority, + ); + } + + Future _buildTask( String id, File file, { + required String group, Map? fields, + String? originalFileName, + String? metadata, + int? priority, }) async { final serverEndpoint = Store.get(StoreKey.serverEndpoint); final url = Uri.parse('$serverEndpoint/assets').toString(); @@ -65,9 +108,8 @@ class UploadService { final stats = await file.stat(); final fileCreatedAt = stats.changed; final fileModifiedAt = stats.modified; - final fieldsMap = { - 'filename': filename, + 'filename': originalFileName ?? filename, 'deviceAssetId': id, 'deviceId': deviceId, 'fileCreatedAt': fileCreatedAt.toUtc().toIso8601String(), @@ -79,6 +121,7 @@ class UploadService { return UploadTask( taskId: id, + displayName: originalFileName ?? filename, httpRequestMethod: 'POST', url: url, headers: headers, @@ -87,7 +130,9 @@ class UploadService { baseDirectory: baseDirectory, directory: directory, fileField: 'assetData', - group: uploadGroup, + metaData: metadata ?? '', + group: group, + priority: priority ?? 5, updates: Updates.statusAndProgress, ); } diff --git a/mobile/lib/utils/database.utils.dart b/mobile/lib/utils/database.utils.dart new file mode 100644 index 0000000000..446b92db19 --- /dev/null +++ b/mobile/lib/utils/database.utils.dart @@ -0,0 +1,31 @@ +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; + +extension LocalAlbumEntityDataHelper on LocalAlbumEntityData { + LocalAlbum toDto({int assetCount = 0}) { + return LocalAlbum( + id: id, + name: name, + updatedAt: updatedAt, + assetCount: assetCount, + backupSelection: backupSelection, + ); + } +} + +extension LocalAssetEntityDataHelper on LocalAssetEntityData { + LocalAsset toDto() { + return LocalAsset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + ); + } +} diff --git a/mobile/lib/utils/upload.dart b/mobile/lib/utils/upload.dart deleted file mode 100644 index a0b77f1d93..0000000000 --- a/mobile/lib/utils/upload.dart +++ /dev/null @@ -1 +0,0 @@ -const uploadGroup = 'upload_group'; diff --git a/mobile/lib/widgets/backup/drift_album_info_list_tile.dart b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart new file mode 100644 index 0000000000..42178c972e --- /dev/null +++ b/mobile/lib/widgets/backup/drift_album_info_list_tile.dart @@ -0,0 +1,121 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; +import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +class DriftAlbumInfoListTile extends HookConsumerWidget { + final LocalAlbum album; + + const DriftAlbumInfoListTile({super.key, required this.album}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bool isSelected = album.backupSelection == BackupSelection.selected; + final bool isExcluded = album.backupSelection == BackupSelection.excluded; + + final syncAlbum = ref + .watch(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); + + buildTileColor() { + if (isSelected) { + return context.isDarkTheme + ? context.primaryColor.withAlpha(100) + : context.primaryColor.withAlpha(25); + } else if (isExcluded) { + return context.isDarkTheme + ? Colors.red[300]?.withAlpha(150) + : Colors.red[100]?.withAlpha(150); + } else { + return Colors.transparent; + } + } + + buildIcon() { + if (isSelected) { + return Icon( + Icons.check_circle_rounded, + color: context.colorScheme.primary, + ); + } + + if (isExcluded) { + return Icon( + Icons.remove_circle_rounded, + color: context.colorScheme.error, + ); + } + + return Icon( + Icons.circle, + color: context.colorScheme.surfaceContainerHighest, + ); + } + + return GestureDetector( + onDoubleTap: () { + ref.watch(hapticFeedbackProvider.notifier).selectionClick(); + + if (isExcluded) { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } else { + if (album.id == 'isAll' || album.name == 'Recents') { + ImmichToast.show( + context: context, + msg: 'Cannot exclude album contains all assets', + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + ref.read(backupAlbumProvider.notifier).excludeAlbum(album); + } + }, + child: ListTile( + tileColor: buildTileColor(), + contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + onTap: () { + ref.read(hapticFeedbackProvider.notifier).selectionClick(); + if (isSelected) { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } else { + ref.read(backupAlbumProvider.notifier).selectAlbum(album); + if (syncAlbum) { + ref.read(albumProvider.notifier).createSyncAlbum(album.name); + } + } + }, + leading: buildIcon(), + title: Text( + album.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text(album.assetCount.toString()), + trailing: IconButton( + onPressed: () { + context.pushRoute(LocalTimelineRoute(album: album)); + }, + icon: Icon( + Icons.image_outlined, + color: context.primaryColor, + size: 24, + ), + splashRadius: 25, + ), + ), + ); + } +} diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index b58a1ad6f9..2b020d20ee 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -205,7 +205,7 @@ class _BackupIndicator extends ConsumerWidget { final badgeBackground = context.colorScheme.surfaceContainer; return InkWell( - onTap: () => context.pushRoute(const BackupControllerRoute()), + onTap: () => context.pushRoute(const DriftBackupRoute()), borderRadius: const BorderRadius.all(Radius.circular(12)), child: Badge( label: Container( From 261818ddd9684884b4091a494e4d76c63d257a61 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:34:17 +0300 Subject: [PATCH 35/45] refactor(mobile): download button in new timeline (#20010) * download button * minor improvements --- i18n/en.json | 1 + .../download_action_button.widget.dart | 40 ++++++- .../asset_viewer/bottom_sheet.widget.dart | 3 +- .../archive_bottom_sheet.widget.dart | 2 +- .../favorite_bottom_sheet.widget.dart | 2 +- .../general_bottom_sheet.widget.dart | 2 +- .../locked_folder_bottom_sheet.widget.dart | 2 +- .../partner_detail_bottom_sheet.widget.dart | 2 +- .../remote_album_bottom_sheet.widget.dart | 2 +- .../infrastructure/action.provider.dart | 24 ++++- .../lib/repositories/download.repository.dart | 102 ++++++++++++++++-- mobile/lib/services/action.service.dart | 9 ++ 12 files changed, 170 insertions(+), 21 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index dfe2954c9f..77dc2e235c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -782,6 +782,7 @@ "documentation": "Documentation", "done": "Done", "download": "Download", + "download_action_prompt": "Downloading {count} assets", "download_canceled": "Download canceled", "download_complete": "Download complete", "download_enqueue": "Download enqueued", diff --git a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart index 53ea5d4946..c6eda703a5 100644 --- a/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/download_action_button.widget.dart @@ -1,16 +1,54 @@ +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class DownloadActionButton extends ConsumerWidget { - const DownloadActionButton({super.key}); + final ActionSource source; + + const DownloadActionButton({super.key, required this.source}); + + void _onTap(BuildContext context, WidgetRef ref) async { + if (!context.mounted) { + return; + } + + final result = await ref.read(actionProvider.notifier).downloadAll(source); + ref.read(multiSelectProvider.notifier).reset(); + + if (!context.mounted) { + return; + } + + if (!result.success) { + ImmichToast.show( + context: context, + msg: 'scaffold_body_error_occurred'.t(context: context), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.error, + ); + } else if (result.count > 0) { + ImmichToast.show( + context: context, + msg: 'download_action_prompt' + .t(context: context, args: {'count': result.count.toString()}), + gravity: ToastGravity.BOTTOM, + toastType: ToastType.success, + ); + } + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.download, label: "download".t(context: context), + onPressed: () => _onTap(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart index a8b7c79588..89822fef91 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.widget.dart @@ -52,7 +52,8 @@ class AssetDetailBottomSheet extends ConsumerWidget { if (asset.hasRemote) ...[ const ShareLinkActionButton(source: ActionSource.viewer), const ArchiveActionButton(source: ActionSource.viewer), - if (!asset.hasLocal) const DownloadActionButton(), + if (!asset.hasLocal) + const DownloadActionButton(source: ActionSource.viewer), isTrashEnable ? const TrashActionButton(source: ActionSource.viewer) : const DeletePermanentActionButton(source: ActionSource.viewer), diff --git a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart index a3d24ec8ee..9ed35da4cd 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/archive_bottom_sheet.widget.dart @@ -38,7 +38,7 @@ class ArchiveBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const UnArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart index eefe19194c..a1e1255a9f 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/favorite_bottom_sheet.widget.dart @@ -38,7 +38,7 @@ class FavoriteBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const UnFavoriteActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart index f9a9dd3203..373d264d82 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart @@ -38,7 +38,7 @@ class GeneralBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart index ef71f3a3a3..7f82f750f7 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/locked_folder_bottom_sheet.widget.dart @@ -18,7 +18,7 @@ class LockedFolderBottomSheet extends ConsumerWidget { shouldCloseOnMinExtent: false, actions: [ ShareActionButton(source: ActionSource.timeline), - DownloadActionButton(), + DownloadActionButton(source: ActionSource.timeline), DeletePermanentActionButton(source: ActionSource.timeline), RemoveFromLockFolderActionButton(source: ActionSource.timeline), ], diff --git a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart index 1cdf6f28d6..5e4dae34bc 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart @@ -16,7 +16,7 @@ class PartnerDetailBottomSheet extends ConsumerWidget { shouldCloseOnMinExtent: false, actions: [ ShareActionButton(source: ActionSource.timeline), - DownloadActionButton(), + DownloadActionButton(source: ActionSource.timeline), ], ); } diff --git a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart index b5fecdf7a4..cfb6fe4f1a 100644 --- a/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_sheet/remote_album_bottom_sheet.widget.dart @@ -41,7 +41,7 @@ class RemoteAlbumBottomSheet extends ConsumerWidget { const ShareLinkActionButton(source: ActionSource.timeline), const ArchiveActionButton(source: ActionSource.timeline), const FavoriteActionButton(source: ActionSource.timeline), - const DownloadActionButton(), + const DownloadActionButton(source: ActionSource.timeline), isTrashEnable ? const TrashActionButton(source: ActionSource.timeline) : const DeletePermanentActionButton( diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart index a0ebf448fc..cb025ef941 100644 --- a/mobile/lib/providers/infrastructure/action.provider.dart +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -41,7 +41,8 @@ class ActionNotifier extends Notifier { } List _getRemoteIdsForSource(ActionSource source) { - return _getIdsForSource(source) + return _getAssets(source) + .whereType() .toIds() .toList(growable: false); } @@ -63,7 +64,8 @@ class ActionNotifier extends Notifier { List _getOwnedRemoteIdsForSource(ActionSource source) { final ownerId = ref.read(currentUserProvider)?.id; - return _getIdsForSource(source) + return _getAssets(source) + .whereType() .ownedAssets(ownerId) .toIds() .toList(growable: false); @@ -331,6 +333,24 @@ class ActionNotifier extends Notifier { ); } } + + Future downloadAll(ActionSource source) async { + final assets = + _getAssets(source).whereType().toList(growable: false); + + try { + final didEnqueue = await _service.downloadAll(assets); + final enqueueCount = didEnqueue.where((e) => e).length; + return ActionResult(count: enqueueCount, success: true); + } catch (error, stack) { + _logger.severe('Failed to download assets', error, stack); + return ActionResult( + count: assets.length, + success: false, + error: error.toString(), + ); + } + } } extension on Iterable { diff --git a/mobile/lib/repositories/download.repository.dart b/mobile/lib/repositories/download.repository.dart index 72f7e065ca..f1dae3c251 100644 --- a/mobile/lib/repositories/download.repository.dart +++ b/mobile/lib/repositories/download.repository.dart @@ -1,10 +1,28 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:background_downloader/background_downloader.dart'; +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/download.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; final downloadRepositoryProvider = Provider((ref) => DownloadRepository()); class DownloadRepository { + static final _downloader = FileDownloader(); + static final _dummyTask = DownloadTask( + taskId: 'dummy', + url: '', + filename: 'dummy', + group: '', + updates: Updates.statusAndProgress, + ); + static final _dummyMetadata = {'part': LivePhotosPart.image, 'id': ''}; + void Function(TaskStatusUpdate)? onImageDownloadStatus; void Function(TaskStatusUpdate)? onVideoDownloadStatus; @@ -14,19 +32,19 @@ class DownloadRepository { void Function(TaskProgressUpdate)? onTaskProgress; DownloadRepository() { - FileDownloader().registerCallbacks( + _downloader.registerCallbacks( group: downloadGroupImage, taskStatusCallback: (update) => onImageDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); - FileDownloader().registerCallbacks( + _downloader.registerCallbacks( group: downloadGroupVideo, taskStatusCallback: (update) => onVideoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), ); - FileDownloader().registerCallbacks( + _downloader.registerCallbacks( group: downloadGroupLivePhoto, taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update), taskProgressCallback: (update) => onTaskProgress?.call(update), @@ -34,25 +52,87 @@ class DownloadRepository { } Future> downloadAll(List tasks) { - return FileDownloader().enqueueAll(tasks); + return _downloader.enqueueAll(tasks); } Future deleteAllTrackingRecords() { - return FileDownloader().database.deleteAllRecords(); + return _downloader.database.deleteAllRecords(); } Future cancel(String id) { - return FileDownloader().cancelTaskWithId(id); + return _downloader.cancelTaskWithId(id); } Future> getLiveVideoTasks() { - return FileDownloader().database.allRecordsWithStatus( - TaskStatus.complete, - group: downloadGroupLivePhoto, - ); + return _downloader.database.allRecordsWithStatus( + TaskStatus.complete, + group: downloadGroupLivePhoto, + ); } Future deleteRecordsWithIds(List ids) { - return FileDownloader().database.deleteRecordsWithIds(ids); + return _downloader.database.deleteRecordsWithIds(ids); + } + + Future> downloadAllAssets(List assets) async { + if (assets.isEmpty) { + return Future.value(const []); + } + + final length = Platform.isAndroid ? assets.length : assets.length * 2; + final tasks = List.filled(length, _dummyTask); + int taskIndex = 0; + final headers = ApiService.getRequestHeaders(); + for (final asset in assets) { + if (!asset.isRemoteOnly) { + continue; + } + + final id = asset.id; + final livePhotoVideoId = asset.livePhotoVideoId; + final isVideo = asset.isVideo; + final url = getOriginalUrlForRemoteId(id); + + if (Platform.isAndroid || livePhotoVideoId == null || isVideo) { + tasks[taskIndex++] = DownloadTask( + taskId: id, + url: url, + headers: headers, + filename: asset.name, + updates: Updates.statusAndProgress, + group: isVideo ? downloadGroupVideo : downloadGroupImage, + ); + continue; + } + + _dummyMetadata['part'] = LivePhotosPart.image; + _dummyMetadata['id'] = id; + tasks[taskIndex++] = DownloadTask( + taskId: id, + url: url, + headers: headers, + filename: asset.name, + updates: Updates.statusAndProgress, + group: downloadGroupLivePhoto, + metaData: json.encode(_dummyMetadata), + ); + + _dummyMetadata['part'] = LivePhotosPart.video; + tasks[taskIndex++] = DownloadTask( + taskId: livePhotoVideoId, + url: url, + headers: headers, + filename: asset.name + .toUpperCase() + .replaceAll(RegExp(r"\.(JPG|HEIC)$"), '.MOV'), + updates: Updates.statusAndProgress, + group: downloadGroupLivePhoto, + metaData: json.encode(_dummyMetadata), + ); + } + if (taskIndex == 0) { + return Future.value(const []); + } + return _downloader.enqueueAll(tasks.slice(0, taskIndex)); } } diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index b59df5b3dc..7b0d74e420 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -1,3 +1,5 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/repositories/download.repository.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; @@ -23,6 +25,7 @@ final actionServiceProvider = Provider( ref.watch(driftAlbumApiRepositoryProvider), ref.watch(remoteAlbumRepository), ref.watch(assetMediaRepositoryProvider), + ref.watch(downloadRepositoryProvider), ), ); @@ -33,6 +36,7 @@ class ActionService { final DriftAlbumApiRepository _albumApiRepository; final DriftRemoteAlbumRepository _remoteAlbumRepository; final AssetMediaRepository _assetMediaRepository; + final DownloadRepository _downloadRepository; const ActionService( this._assetApiRepository, @@ -41,6 +45,7 @@ class ActionService { this._albumApiRepository, this._remoteAlbumRepository, this._assetMediaRepository, + this._downloadRepository, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -191,4 +196,8 @@ class ActionService { Future shareAssets(List assets) { return _assetMediaRepository.shareAssets(assets); } + + Future> downloadAll(List assets) { + return _downloadRepository.downloadAllAssets(assets); + } } From dee6d072fb3762ff718562e4de963c9159c5210c Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 20 Jul 2025 13:56:53 -0500 Subject: [PATCH 36/45] fix: memory jarring hero animation (#20030) * fix: memory jarring hero animation * remove other hero --- .../widgets/memory/memory_card.widget.dart | 38 ++++++++----------- .../widgets/memory/memory_lane.widget.dart | 15 +++----- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart index a6262bd2e5..268bbc30c0 100644 --- a/mobile/lib/presentation/widgets/memory/memory_card.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_card.widget.dart @@ -57,30 +57,24 @@ class DriftMemoryCard extends StatelessWidget { } if (asset.isImage) { - return Hero( - tag: 'memory-${asset.id}', - child: FullImage( - asset, - fit: fit, - size: const Size(double.infinity, double.infinity), - ), + return FullImage( + asset, + fit: fit, + size: const Size(double.infinity, double.infinity), ); } else { - return Hero( - tag: 'memory-${asset.id}', - child: SizedBox( - width: context.width, - height: context.height, - child: NativeVideoViewer( - key: ValueKey(asset.id), - asset: asset, - showControls: false, - playbackDelayFactor: 2, - image: FullImage( - asset, - size: Size(context.width, context.height), - fit: BoxFit.contain, - ), + return SizedBox( + width: context.width, + height: context.height, + child: NativeVideoViewer( + key: ValueKey(asset.id), + asset: asset, + showControls: false, + playbackDelayFactor: 2, + image: FullImage( + asset, + size: Size(context.width, context.height), + fit: BoxFit.contain, ), ), ); diff --git a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart index aa21f36dd1..403d8de061 100644 --- a/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart +++ b/mobile/lib/presentation/widgets/memory/memory_lane.widget.dart @@ -79,15 +79,12 @@ class DriftMemoryCard extends ConsumerWidget { Colors.black.withValues(alpha: 0.2), BlendMode.darken, ), - child: Hero( - tag: 'memory-${memory.assets[0].id}', - child: SizedBox( - width: 205, - height: 200, - child: Thumbnail( - remoteId: memory.assets[0].id, - fit: BoxFit.cover, - ), + child: SizedBox( + width: 205, + height: 200, + child: Thumbnail( + remoteId: memory.assets[0].id, + fit: BoxFit.cover, ), ), ), From 5fc4393e7a2c51584dcd7d2a3d2c7b3ba42ff8eb Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 21 Jul 2025 23:26:49 +0530 Subject: [PATCH 37/45] feat: migrate backup albums to sqlite (#20049) * do not migrate again on app start * migrate backup albums over to sqlite --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/entities/backup_album.entity.dart | 12 +++ .../pages/common/change_experience.page.dart | 4 + mobile/lib/utils/migration.dart | 85 ++++++++++++++++--- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/mobile/lib/entities/backup_album.entity.dart b/mobile/lib/entities/backup_album.entity.dart index 4d4d7b3aa3..1e96c0452e 100644 --- a/mobile/lib/entities/backup_album.entity.dart +++ b/mobile/lib/entities/backup_album.entity.dart @@ -13,6 +13,18 @@ class BackupAlbum { BackupAlbum(this.id, this.lastBackup, this.selection); Id get isarId => fastHash(id); + + BackupAlbum copyWith({ + String? id, + DateTime? lastBackup, + BackupSelection? selection, + }) { + return BackupAlbum( + id ?? this.id, + lastBackup ?? this.lastBackup, + selection ?? this.selection, + ); + } } enum BackupSelection { diff --git a/mobile/lib/pages/common/change_experience.page.dart b/mobile/lib/pages/common/change_experience.page.dart index 5d298edb42..a8569b25a0 100644 --- a/mobile/lib/pages/common/change_experience.page.dart +++ b/mobile/lib/pages/common/change_experience.page.dart @@ -56,6 +56,10 @@ class _ChangeExperiencePageState extends ConsumerState { ref.read(isarProvider), ref.read(driftProvider), ); + await migrateBackupAlbumsToSqlite( + ref.read(isarProvider), + ref.read(driftProvider), + ); } } else { await ref.read(backgroundSyncProvider).cancel(); diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index d09f2b9978..a95c376ac2 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -2,19 +2,23 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; -import 'package:immich_mobile/domain/utils/background_sync.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/entities/backup_album.entity.dart' + as isar_backup_album; import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/ios_device_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/store.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; @@ -27,7 +31,7 @@ import 'package:logging/logging.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 14; +const int targetVersion = 13; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, targetVersion); @@ -56,18 +60,6 @@ Future migrateDatabaseIfNeeded(Isar db) async { await Store.put(StoreKey.photoManagerCustomFilter, true); } - if (version < 14) { - if (!Store.isBetaTimelineEnabled) { - // Try again when beta timeline is enabled and the app is restarted - return; - } - final backgroundSync = BackgroundSyncManager(); - await backgroundSync.syncLocal(); - final drift = Drift(); - await migrateDeviceAssetToSqlite(db, drift); - await drift.close(); - } - if (targetVersion >= 12) { await Store.put(StoreKey.version, targetVersion); return; @@ -204,6 +196,71 @@ Future migrateDeviceAssetToSqlite(Isar db, Drift drift) async { } } +Future migrateBackupAlbumsToSqlite( + Isar db, + Drift drift, +) async { + try { + final isarBackupAlbums = await db.backupAlbums.where().findAll(); + // Recents is a virtual album on Android, and we don't have it with the new sync + // If recents is selected previously, select all albums during migration except the excluded ones + if (Platform.isAndroid) { + final recentAlbum = + isarBackupAlbums.firstWhereOrNull((album) => album.id == 'isAll'); + if (recentAlbum != null) { + await drift.localAlbumEntity.update().write( + const LocalAlbumEntityCompanion( + backupSelection: Value(BackupSelection.selected), + ), + ); + final excluded = isarBackupAlbums + .where( + (album) => + album.selection == isar_backup_album.BackupSelection.exclude, + ) + .map((album) => album.id) + .toList(); + await drift.batch((batch) async { + for (final id in excluded) { + batch.update( + drift.localAlbumEntity, + const LocalAlbumEntityCompanion( + backupSelection: Value(BackupSelection.excluded), + ), + where: (t) => t.id.equals(id), + ); + } + }); + } + return; + } + + await drift.batch((batch) { + for (final album in isarBackupAlbums) { + batch.update( + drift.localAlbumEntity, + LocalAlbumEntityCompanion( + backupSelection: Value( + switch (album.selection) { + isar_backup_album.BackupSelection.none => BackupSelection.none, + isar_backup_album.BackupSelection.select => + BackupSelection.selected, + isar_backup_album.BackupSelection.exclude => + BackupSelection.excluded, + }, + ), + ), + where: (t) => t.id.equals(album.id), + ); + } + }); + } catch (error) { + debugPrint( + "[MIGRATION] Error while migrating backup albums to SQLite: $error", + ); + } +} + class _DeviceAsset { final String assetId; final List? hash; From 1dc62fce5f16425020aa3644672a2b4025c1047e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 22 Jul 2025 01:50:41 +0530 Subject: [PATCH 38/45] fix: remove foreign constraint on stack.primaryAssetId (#20052) * fix: remove foreign constraint in stack.primaryAssetId * fix migration --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../drift_schemas/main/drift_schema_v3.json | 1 + .../infrastructure/entities/stack.entity.dart | 3 +- .../entities/stack.entity.drift.dart | 135 +- .../repositories/db.repository.dart | 10 +- .../repositories/db.repository.steps.dart | 355 ++ .../repositories/remote_asset.repository.dart | 2 +- mobile/test/drift/main/generated/schema.dart | 5 +- .../test/drift/main/generated/schema_v3.dart | 5136 +++++++++++++++++ 8 files changed, 5521 insertions(+), 126 deletions(-) create mode 100644 mobile/drift_schemas/main/drift_schema_v3.json create mode 100644 mobile/test/drift/main/generated/schema_v3.dart diff --git a/mobile/drift_schemas/main/drift_schema_v3.json b/mobile/drift_schemas/main/drift_schema_v3.json new file mode 100644 index 0000000000..1acfbaf493 --- /dev/null +++ b/mobile/drift_schemas/main/drift_schema_v3.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"live_photo_video_id","getter_name":"livePhotoVideoId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}},{"name":"stack_id","getter_name":"stackId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[0],"type":"table","data":{"name":"stack_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"primary_asset_id","getter_name":"primaryAssetId","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":6,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":7,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"key","getter_name":"key","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(UserMetadataKey.values)","dart_type_name":"UserMetadataKey"}},{"name":"value","getter_name":"value","moor_type":"blob","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userMetadataConverter","dart_type_name":"Map"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id","key"]}},{"id":8,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":9,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":10,"references":[2,9],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":11,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"lens","getter_name":"lens","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":12,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('\\'\\'')","default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":13,"references":[1,12],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":14,"references":[12,0],"type":"table","data":{"name":"remote_album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}},{"id":15,"references":[0],"type":"table","data":{"name":"memory_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(MemoryTypeEnum.values)","dart_type_name":"MemoryTypeEnum"}},{"name":"data","getter_name":"data","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_saved","getter_name":"isSaved","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_saved\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_saved\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"memory_at","getter_name":"memoryAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"seen_at","getter_name":"seenAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"show_at","getter_name":"showAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"hide_at","getter_name":"hideAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":16,"references":[1,15],"type":"table","data":{"name":"memory_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"memory_id","getter_name":"memoryId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES memory_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES memory_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","memory_id"]}},{"id":17,"references":[0],"type":"table","data":{"name":"person_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"face_asset_id","getter_name":"faceAssetId","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumbnail_path","getter_name":"thumbnailPath","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_hidden","getter_name":"isHidden","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_hidden\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_hidden\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"color","getter_name":"color","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"birth_date","getter_name":"birthDate","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}}]} \ No newline at end of file diff --git a/mobile/lib/infrastructure/entities/stack.entity.dart b/mobile/lib/infrastructure/entities/stack.entity.dart index 92375f19db..b5da42832e 100644 --- a/mobile/lib/infrastructure/entities/stack.entity.dart +++ b/mobile/lib/infrastructure/entities/stack.entity.dart @@ -1,5 +1,4 @@ import 'package:drift/drift.dart'; -import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @@ -15,7 +14,7 @@ class StackEntity extends Table with DriftDefaultsMixin { TextColumn get ownerId => text().references(UserEntity, #id, onDelete: KeyAction.cascade)(); - TextColumn get primaryAssetId => text().references(RemoteAssetEntity, #id)(); + TextColumn get primaryAssetId => text()(); @override Set get primaryKey => {id}; diff --git a/mobile/lib/infrastructure/entities/stack.entity.drift.dart b/mobile/lib/infrastructure/entities/stack.entity.drift.dart index c0d000e02a..df0390aea0 100644 --- a/mobile/lib/infrastructure/entities/stack.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/stack.entity.drift.dart @@ -8,8 +8,6 @@ import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart' as i4; import 'package:drift/internal/modular.dart' as i5; -import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' - as i6; typedef $$StackEntityTableCreateCompanionBuilder = i1.StackEntityCompanion Function({ @@ -57,33 +55,6 @@ final class $$StackEntityTableReferences extends i0.BaseReferences< return i0.ProcessedTableManager( manager.$state.copyWith(prefetchedData: [item])); } - - static i6.$RemoteAssetEntityTable _primaryAssetIdTable( - i0.GeneratedDatabase db) => - i5.ReadDatabaseContainer(db) - .resultSet('remote_asset_entity') - .createAlias(i0.$_aliasNameGenerator( - i5.ReadDatabaseContainer(db) - .resultSet('stack_entity') - .primaryAssetId, - i5.ReadDatabaseContainer(db) - .resultSet('remote_asset_entity') - .id)); - - i6.$$RemoteAssetEntityTableProcessedTableManager get primaryAssetId { - final $_column = $_itemColumn('primary_asset_id')!; - - final manager = i6 - .$$RemoteAssetEntityTableTableManager( - $_db, - i5.ReadDatabaseContainer($_db) - .resultSet('remote_asset_entity')) - .filter((f) => f.id.sqlEquals($_column)); - final item = $_typedResult.readTableOrNull(_primaryAssetIdTable($_db)); - if (item == null) return manager; - return i0.ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); - } } class $$StackEntityTableFilterComposer @@ -104,6 +75,10 @@ class $$StackEntityTableFilterComposer i0.ColumnFilters get updatedAt => $composableBuilder( column: $table.updatedAt, builder: (column) => i0.ColumnFilters(column)); + i0.ColumnFilters get primaryAssetId => $composableBuilder( + column: $table.primaryAssetId, + builder: (column) => i0.ColumnFilters(column)); + i4.$$UserEntityTableFilterComposer get ownerId { final i4.$$UserEntityTableFilterComposer composer = $composerBuilder( composer: this, @@ -125,28 +100,6 @@ class $$StackEntityTableFilterComposer )); return composer; } - - i6.$$RemoteAssetEntityTableFilterComposer get primaryAssetId { - final i6.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.primaryAssetId, - referencedTable: i5.ReadDatabaseContainer($db) - .resultSet('remote_asset_entity'), - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - i6.$$RemoteAssetEntityTableFilterComposer( - $db: $db, - $table: i5.ReadDatabaseContainer($db) - .resultSet('remote_asset_entity'), - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return composer; - } } class $$StackEntityTableOrderingComposer @@ -169,6 +122,10 @@ class $$StackEntityTableOrderingComposer column: $table.updatedAt, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get primaryAssetId => $composableBuilder( + column: $table.primaryAssetId, + builder: (column) => i0.ColumnOrderings(column)); + i4.$$UserEntityTableOrderingComposer get ownerId { final i4.$$UserEntityTableOrderingComposer composer = $composerBuilder( composer: this, @@ -190,30 +147,6 @@ class $$StackEntityTableOrderingComposer )); return composer; } - - i6.$$RemoteAssetEntityTableOrderingComposer get primaryAssetId { - final i6.$$RemoteAssetEntityTableOrderingComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.primaryAssetId, - referencedTable: i5.ReadDatabaseContainer($db) - .resultSet('remote_asset_entity'), - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - i6.$$RemoteAssetEntityTableOrderingComposer( - $db: $db, - $table: i5.ReadDatabaseContainer($db) - .resultSet( - 'remote_asset_entity'), - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return composer; - } } class $$StackEntityTableAnnotationComposer @@ -234,6 +167,9 @@ class $$StackEntityTableAnnotationComposer i0.GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); + i0.GeneratedColumn get primaryAssetId => $composableBuilder( + column: $table.primaryAssetId, builder: (column) => column); + i4.$$UserEntityTableAnnotationComposer get ownerId { final i4.$$UserEntityTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -255,30 +191,6 @@ class $$StackEntityTableAnnotationComposer )); return composer; } - - i6.$$RemoteAssetEntityTableAnnotationComposer get primaryAssetId { - final i6.$$RemoteAssetEntityTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.primaryAssetId, - referencedTable: i5.ReadDatabaseContainer($db) - .resultSet('remote_asset_entity'), - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - i6.$$RemoteAssetEntityTableAnnotationComposer( - $db: $db, - $table: i5.ReadDatabaseContainer($db) - .resultSet( - 'remote_asset_entity'), - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return composer; - } } class $$StackEntityTableTableManager extends i0.RootTableManager< @@ -292,7 +204,7 @@ class $$StackEntityTableTableManager extends i0.RootTableManager< $$StackEntityTableUpdateCompanionBuilder, (i1.StackEntityData, i1.$$StackEntityTableReferences), i1.StackEntityData, - i0.PrefetchHooks Function({bool ownerId, bool primaryAssetId})> { + i0.PrefetchHooks Function({bool ownerId})> { $$StackEntityTableTableManager( i0.GeneratedDatabase db, i1.$StackEntityTable table) : super(i0.TableManagerState( @@ -338,7 +250,7 @@ class $$StackEntityTableTableManager extends i0.RootTableManager< i1.$$StackEntityTableReferences(db, table, e) )) .toList(), - prefetchHooksCallback: ({ownerId = false, primaryAssetId = false}) { + prefetchHooksCallback: ({ownerId = false}) { return i0.PrefetchHooks( db: db, explicitlyWatchedTables: [], @@ -365,17 +277,6 @@ class $$StackEntityTableTableManager extends i0.RootTableManager< i1.$$StackEntityTableReferences._ownerIdTable(db).id, ) as T; } - if (primaryAssetId) { - state = state.withJoin( - currentTable: table, - currentColumn: table.primaryAssetId, - referencedTable: i1.$$StackEntityTableReferences - ._primaryAssetIdTable(db), - referencedColumn: i1.$$StackEntityTableReferences - ._primaryAssetIdTable(db) - .id, - ) as T; - } return state; }, @@ -398,7 +299,7 @@ typedef $$StackEntityTableProcessedTableManager = i0.ProcessedTableManager< $$StackEntityTableUpdateCompanionBuilder, (i1.StackEntityData, i1.$$StackEntityTableReferences), i1.StackEntityData, - i0.PrefetchHooks Function({bool ownerId, bool primaryAssetId})>; + i0.PrefetchHooks Function({bool ownerId})>; class $StackEntityTable extends i2.StackEntity with i0.TableInfo<$StackEntityTable, i1.StackEntityData> { @@ -440,12 +341,8 @@ class $StackEntityTable extends i2.StackEntity const i0.VerificationMeta('primaryAssetId'); @override late final i0.GeneratedColumn primaryAssetId = - i0.GeneratedColumn( - 'primary_asset_id', aliasedName, false, - type: i0.DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: i0.GeneratedColumn.constraintIsAlways( - 'REFERENCES remote_asset_entity (id)')); + i0.GeneratedColumn('primary_asset_id', aliasedName, false, + type: i0.DriftSqlType.string, requiredDuringInsert: true); @override List get $columns => [id, createdAt, updatedAt, ownerId, primaryAssetId]; diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 0a763c91cc..7562cf6ff5 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -72,7 +72,7 @@ class Drift extends $Drift implements IDatabaseRepository { ); @override - int get schemaVersion => 2; + int get schemaVersion => 3; @override MigrationStrategy get migration => MigrationStrategy( @@ -84,12 +84,16 @@ class Drift extends $Drift implements IDatabaseRepository { from: from, to: to, steps: migrationSteps( - from1To2: (m, _) async { - for (final entity in allSchemaEntities) { + from1To2: (m, v2) async { + for (final entity in v2.entities) { await m.drop(entity); await m.create(entity); } }, + from2To3: (m, v3) async { + // Removed foreign key constraint on stack.primaryAssetId + await m.alterTable(TableMigration(v3.stackEntity)); + }, ), ); diff --git a/mobile/lib/infrastructure/repositories/db.repository.steps.dart b/mobile/lib/infrastructure/repositories/db.repository.steps.dart index 76e5df76d0..a0703c3714 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.steps.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.steps.dart @@ -919,8 +919,356 @@ i1.GeneratedColumn _column_73(String aliasedName) => i1.GeneratedColumn _column_74(String aliasedName) => i1.GeneratedColumn('birth_date', aliasedName, true, type: i1.DriftSqlType.dateTime); + +final class Schema3 extends i0.VersionedSchema { + Schema3({required super.database}) : super(version: 3); + @override + late final List entities = [ + userEntity, + remoteAssetEntity, + localAssetEntity, + stackEntity, + idxLocalAssetChecksum, + uQRemoteAssetOwnerChecksum, + idxRemoteAssetChecksum, + userMetadataEntity, + partnerEntity, + localAlbumEntity, + localAlbumAssetEntity, + remoteExifEntity, + remoteAlbumEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + personEntity, + ]; + late final Shape0 userEntity = Shape0( + source: i0.VersionedTable( + entityName: 'user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape1 remoteAssetEntity = Shape1( + source: i0.VersionedTable( + entityName: 'remote_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_13, + _column_14, + _column_15, + _column_16, + _column_17, + _column_18, + _column_19, + _column_20, + _column_21, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape2 localAssetEntity = Shape2( + source: i0.VersionedTable( + entityName: 'local_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_1, + _column_8, + _column_9, + _column_5, + _column_10, + _column_11, + _column_12, + _column_0, + _column_22, + _column_14, + _column_23, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape3 stackEntity = Shape3( + source: i0.VersionedTable( + entityName: 'stack_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_75, + ], + attachedDatabase: database, + ), + alias: null); + final i1.Index idxLocalAssetChecksum = i1.Index('idx_local_asset_checksum', + 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); + final i1.Index uQRemoteAssetOwnerChecksum = i1.Index( + 'UQ_remote_asset_owner_checksum', + 'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)'); + final i1.Index idxRemoteAssetChecksum = i1.Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); + late final Shape4 userMetadataEntity = Shape4( + source: i0.VersionedTable( + entityName: 'user_metadata_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(user_id, "key")', + ], + columns: [ + _column_25, + _column_26, + _column_27, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape5 partnerEntity = Shape5( + source: i0.VersionedTable( + entityName: 'partner_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(shared_by_id, shared_with_id)', + ], + columns: [ + _column_28, + _column_29, + _column_30, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape6 localAlbumEntity = Shape6( + source: i0.VersionedTable( + entityName: 'local_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_5, + _column_31, + _column_32, + _column_33, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape7 localAlbumAssetEntity = Shape7( + source: i0.VersionedTable( + entityName: 'local_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id, album_id)', + ], + columns: [ + _column_34, + _column_35, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape8 remoteExifEntity = Shape8( + source: i0.VersionedTable( + entityName: 'remote_exif_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id)', + ], + columns: [ + _column_36, + _column_37, + _column_38, + _column_39, + _column_40, + _column_41, + _column_11, + _column_10, + _column_42, + _column_43, + _column_44, + _column_45, + _column_46, + _column_47, + _column_48, + _column_49, + _column_50, + _column_51, + _column_52, + _column_53, + _column_54, + _column_55, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape9 remoteAlbumEntity = Shape9( + source: i0.VersionedTable( + entityName: 'remote_album_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_1, + _column_56, + _column_9, + _column_5, + _column_15, + _column_57, + _column_58, + _column_59, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape7 remoteAlbumAssetEntity = Shape7( + source: i0.VersionedTable( + entityName: 'remote_album_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id, album_id)', + ], + columns: [ + _column_36, + _column_60, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape10 remoteAlbumUserEntity = Shape10( + source: i0.VersionedTable( + entityName: 'remote_album_user_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(album_id, user_id)', + ], + columns: [ + _column_60, + _column_25, + _column_61, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape11 memoryEntity = Shape11( + source: i0.VersionedTable( + entityName: 'memory_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_18, + _column_15, + _column_8, + _column_62, + _column_63, + _column_64, + _column_65, + _column_66, + _column_67, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape12 memoryAssetEntity = Shape12( + source: i0.VersionedTable( + entityName: 'memory_asset_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(asset_id, memory_id)', + ], + columns: [ + _column_36, + _column_68, + ], + attachedDatabase: database, + ), + alias: null); + late final Shape13 personEntity = Shape13( + source: i0.VersionedTable( + entityName: 'person_entity', + withoutRowId: true, + isStrict: true, + tableConstraints: [ + 'PRIMARY KEY(id)', + ], + columns: [ + _column_0, + _column_9, + _column_5, + _column_15, + _column_1, + _column_69, + _column_70, + _column_71, + _column_72, + _column_73, + _column_74, + ], + attachedDatabase: database, + ), + alias: null); +} + +i1.GeneratedColumn _column_75(String aliasedName) => + i1.GeneratedColumn('primary_asset_id', aliasedName, false, + type: i1.DriftSqlType.string); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, + required Future Function(i1.Migrator m, Schema3 schema) from2To3, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -929,6 +1277,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from1To2(migrator, schema); return 2; + case 2: + final schema = Schema3(database: database); + final migrator = i1.Migrator(database, schema); + await from2To3(migrator, schema); + return 3; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -937,8 +1290,10 @@ i0.MigrationStepWithVersion migrationSteps({ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, + required Future Function(i1.Migrator m, Schema3 schema) from2To3, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, + from2To3: from2To3, )); diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 28c229b46b..52cfe2e7c2 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -193,7 +193,7 @@ class RemoteAssetRepository extends DriftDatabaseRepository { Future stack(String userId, StackResponse stack) { return _db.transaction(() async { final stackIds = await _db.managers.stackEntity - .filter((row) => row.primaryAssetId.id.isIn(stack.assetIds)) + .filter((row) => row.primaryAssetId.isIn(stack.assetIds)) .map((row) => row.id) .get(); diff --git a/mobile/test/drift/main/generated/schema.dart b/mobile/test/drift/main/generated/schema.dart index b2b7404b5a..209e70d788 100644 --- a/mobile/test/drift/main/generated/schema.dart +++ b/mobile/test/drift/main/generated/schema.dart @@ -5,6 +5,7 @@ import 'package:drift/drift.dart'; import 'package:drift/internal/migrations.dart'; import 'schema_v1.dart' as v1; import 'schema_v2.dart' as v2; +import 'schema_v3.dart' as v3; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -14,10 +15,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v1.DatabaseAtV1(db); case 2: return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2]; + static const versions = const [1, 2, 3]; } diff --git a/mobile/test/drift/main/generated/schema_v3.dart b/mobile/test/drift/main/generated/schema_v3.dart new file mode 100644 index 0000000000..8f655b3f7d --- /dev/null +++ b/mobile/test/drift/main/generated/schema_v3.dart @@ -0,0 +1,5136 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class UserEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isAdmin = GeneratedColumn( + 'is_admin', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_admin" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn email = GeneratedColumn( + 'email', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn profileImagePath = GeneratedColumn( + 'profile_image_path', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn quotaSizeInBytes = GeneratedColumn( + 'quota_size_in_bytes', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn quotaUsageInBytes = GeneratedColumn( + 'quota_usage_in_bytes', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0')); + @override + List get $columns => [ + id, + name, + isAdmin, + email, + profileImagePath, + updatedAt, + quotaSizeInBytes, + quotaUsageInBytes + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_entity'; + @override + Set get $primaryKey => {id}; + @override + UserEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + isAdmin: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_admin'])!, + email: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}email'])!, + profileImagePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}profile_image_path']), + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + quotaSizeInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}quota_size_in_bytes']), + quotaUsageInBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}quota_usage_in_bytes'])!, + ); + } + + @override + UserEntity createAlias(String alias) { + return UserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserEntityData extends DataClass implements Insertable { + final String id; + final String name; + final bool isAdmin; + final String email; + final String? profileImagePath; + final DateTime updatedAt; + final int? quotaSizeInBytes; + final int quotaUsageInBytes; + const UserEntityData( + {required this.id, + required this.name, + required this.isAdmin, + required this.email, + this.profileImagePath, + required this.updatedAt, + this.quotaSizeInBytes, + required this.quotaUsageInBytes}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['is_admin'] = Variable(isAdmin); + map['email'] = Variable(email); + if (!nullToAbsent || profileImagePath != null) { + map['profile_image_path'] = Variable(profileImagePath); + } + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || quotaSizeInBytes != null) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes); + } + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes); + return map; + } + + factory UserEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + isAdmin: serializer.fromJson(json['isAdmin']), + email: serializer.fromJson(json['email']), + profileImagePath: serializer.fromJson(json['profileImagePath']), + updatedAt: serializer.fromJson(json['updatedAt']), + quotaSizeInBytes: serializer.fromJson(json['quotaSizeInBytes']), + quotaUsageInBytes: serializer.fromJson(json['quotaUsageInBytes']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'isAdmin': serializer.toJson(isAdmin), + 'email': serializer.toJson(email), + 'profileImagePath': serializer.toJson(profileImagePath), + 'updatedAt': serializer.toJson(updatedAt), + 'quotaSizeInBytes': serializer.toJson(quotaSizeInBytes), + 'quotaUsageInBytes': serializer.toJson(quotaUsageInBytes), + }; + } + + UserEntityData copyWith( + {String? id, + String? name, + bool? isAdmin, + String? email, + Value profileImagePath = const Value.absent(), + DateTime? updatedAt, + Value quotaSizeInBytes = const Value.absent(), + int? quotaUsageInBytes}) => + UserEntityData( + id: id ?? this.id, + name: name ?? this.name, + isAdmin: isAdmin ?? this.isAdmin, + email: email ?? this.email, + profileImagePath: profileImagePath.present + ? profileImagePath.value + : this.profileImagePath, + updatedAt: updatedAt ?? this.updatedAt, + quotaSizeInBytes: quotaSizeInBytes.present + ? quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + ); + UserEntityData copyWithCompanion(UserEntityCompanion data) { + return UserEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + isAdmin: data.isAdmin.present ? data.isAdmin.value : this.isAdmin, + email: data.email.present ? data.email.value : this.email, + profileImagePath: data.profileImagePath.present + ? data.profileImagePath.value + : this.profileImagePath, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + quotaSizeInBytes: data.quotaSizeInBytes.present + ? data.quotaSizeInBytes.value + : this.quotaSizeInBytes, + quotaUsageInBytes: data.quotaUsageInBytes.present + ? data.quotaUsageInBytes.value + : this.quotaUsageInBytes, + ); + } + + @override + String toString() { + return (StringBuffer('UserEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isAdmin: $isAdmin, ') + ..write('email: $email, ') + ..write('profileImagePath: $profileImagePath, ') + ..write('updatedAt: $updatedAt, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, isAdmin, email, profileImagePath, + updatedAt, quotaSizeInBytes, quotaUsageInBytes); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserEntityData && + other.id == this.id && + other.name == this.name && + other.isAdmin == this.isAdmin && + other.email == this.email && + other.profileImagePath == this.profileImagePath && + other.updatedAt == this.updatedAt && + other.quotaSizeInBytes == this.quotaSizeInBytes && + other.quotaUsageInBytes == this.quotaUsageInBytes); +} + +class UserEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value isAdmin; + final Value email; + final Value profileImagePath; + final Value updatedAt; + final Value quotaSizeInBytes; + final Value quotaUsageInBytes; + const UserEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.isAdmin = const Value.absent(), + this.email = const Value.absent(), + this.profileImagePath = const Value.absent(), + this.updatedAt = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + }); + UserEntityCompanion.insert({ + required String id, + required String name, + this.isAdmin = const Value.absent(), + required String email, + this.profileImagePath = const Value.absent(), + this.updatedAt = const Value.absent(), + this.quotaSizeInBytes = const Value.absent(), + this.quotaUsageInBytes = const Value.absent(), + }) : id = Value(id), + name = Value(name), + email = Value(email); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? isAdmin, + Expression? email, + Expression? profileImagePath, + Expression? updatedAt, + Expression? quotaSizeInBytes, + Expression? quotaUsageInBytes, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (isAdmin != null) 'is_admin': isAdmin, + if (email != null) 'email': email, + if (profileImagePath != null) 'profile_image_path': profileImagePath, + if (updatedAt != null) 'updated_at': updatedAt, + if (quotaSizeInBytes != null) 'quota_size_in_bytes': quotaSizeInBytes, + if (quotaUsageInBytes != null) 'quota_usage_in_bytes': quotaUsageInBytes, + }); + } + + UserEntityCompanion copyWith( + {Value? id, + Value? name, + Value? isAdmin, + Value? email, + Value? profileImagePath, + Value? updatedAt, + Value? quotaSizeInBytes, + Value? quotaUsageInBytes}) { + return UserEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + isAdmin: isAdmin ?? this.isAdmin, + email: email ?? this.email, + profileImagePath: profileImagePath ?? this.profileImagePath, + updatedAt: updatedAt ?? this.updatedAt, + quotaSizeInBytes: quotaSizeInBytes ?? this.quotaSizeInBytes, + quotaUsageInBytes: quotaUsageInBytes ?? this.quotaUsageInBytes, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (isAdmin.present) { + map['is_admin'] = Variable(isAdmin.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (profileImagePath.present) { + map['profile_image_path'] = Variable(profileImagePath.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (quotaSizeInBytes.present) { + map['quota_size_in_bytes'] = Variable(quotaSizeInBytes.value); + } + if (quotaUsageInBytes.present) { + map['quota_usage_in_bytes'] = Variable(quotaUsageInBytes.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('isAdmin: $isAdmin, ') + ..write('email: $email, ') + ..write('profileImagePath: $profileImagePath, ') + ..write('updatedAt: $updatedAt, ') + ..write('quotaSizeInBytes: $quotaSizeInBytes, ') + ..write('quotaUsageInBytes: $quotaUsageInBytes') + ..write(')')) + .toString(); + } +} + +class RemoteAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn localDateTime = + GeneratedColumn('local_date_time', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn thumbHash = GeneratedColumn( + 'thumb_hash', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn livePhotoVideoId = GeneratedColumn( + 'live_photo_video_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn visibility = GeneratedColumn( + 'visibility', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn stackId = GeneratedColumn( + 'stack_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAssetEntityData( + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}checksum'])!, + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + localDateTime: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}local_date_time']), + thumbHash: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumb_hash']), + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + livePhotoVideoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}live_photo_video_id']), + visibility: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}visibility'])!, + stackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}stack_id']), + ); + } + + @override + RemoteAssetEntity createAlias(String alias) { + return RemoteAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String checksum; + final bool isFavorite; + final String ownerId; + final DateTime? localDateTime; + final String? thumbHash; + final DateTime? deletedAt; + final String? livePhotoVideoId; + final int visibility; + final String? stackId; + const RemoteAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + required this.checksum, + required this.isFavorite, + required this.ownerId, + this.localDateTime, + this.thumbHash, + this.deletedAt, + this.livePhotoVideoId, + required this.visibility, + this.stackId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + map['checksum'] = Variable(checksum); + map['is_favorite'] = Variable(isFavorite); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || localDateTime != null) { + map['local_date_time'] = Variable(localDateTime); + } + if (!nullToAbsent || thumbHash != null) { + map['thumb_hash'] = Variable(thumbHash); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || livePhotoVideoId != null) { + map['live_photo_video_id'] = Variable(livePhotoVideoId); + } + map['visibility'] = Variable(visibility); + if (!nullToAbsent || stackId != null) { + map['stack_id'] = Variable(stackId); + } + return map; + } + + factory RemoteAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + ownerId: serializer.fromJson(json['ownerId']), + localDateTime: serializer.fromJson(json['localDateTime']), + thumbHash: serializer.fromJson(json['thumbHash']), + deletedAt: serializer.fromJson(json['deletedAt']), + livePhotoVideoId: serializer.fromJson(json['livePhotoVideoId']), + visibility: serializer.fromJson(json['visibility']), + stackId: serializer.fromJson(json['stackId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'ownerId': serializer.toJson(ownerId), + 'localDateTime': serializer.toJson(localDateTime), + 'thumbHash': serializer.toJson(thumbHash), + 'deletedAt': serializer.toJson(deletedAt), + 'livePhotoVideoId': serializer.toJson(livePhotoVideoId), + 'visibility': serializer.toJson(visibility), + 'stackId': serializer.toJson(stackId), + }; + } + + RemoteAssetEntityData copyWith( + {String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + String? checksum, + bool? isFavorite, + String? ownerId, + Value localDateTime = const Value.absent(), + Value thumbHash = const Value.absent(), + Value deletedAt = const Value.absent(), + Value livePhotoVideoId = const Value.absent(), + int? visibility, + Value stackId = const Value.absent()}) => + RemoteAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: + localDateTime.present ? localDateTime.value : this.localDateTime, + thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + livePhotoVideoId: livePhotoVideoId.present + ? livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId.present ? stackId.value : this.stackId, + ); + RemoteAssetEntityData copyWithCompanion(RemoteAssetEntityCompanion data) { + return RemoteAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + localDateTime: data.localDateTime.present + ? data.localDateTime.value + : this.localDateTime, + thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + livePhotoVideoId: data.livePhotoVideoId.present + ? data.livePhotoVideoId.value + : this.livePhotoVideoId, + visibility: + data.visibility.present ? data.visibility.value : this.visibility, + stackId: data.stackId.present ? data.stackId.value : this.stackId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + ownerId, + localDateTime, + thumbHash, + deletedAt, + livePhotoVideoId, + visibility, + stackId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.ownerId == this.ownerId && + other.localDateTime == this.localDateTime && + other.thumbHash == this.thumbHash && + other.deletedAt == this.deletedAt && + other.livePhotoVideoId == this.livePhotoVideoId && + other.visibility == this.visibility && + other.stackId == this.stackId); +} + +class RemoteAssetEntityCompanion + extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value ownerId; + final Value localDateTime; + final Value thumbHash; + final Value deletedAt; + final Value livePhotoVideoId; + final Value visibility; + final Value stackId; + const RemoteAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.ownerId = const Value.absent(), + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + this.visibility = const Value.absent(), + this.stackId = const Value.absent(), + }); + RemoteAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + required String checksum, + this.isFavorite = const Value.absent(), + required String ownerId, + this.localDateTime = const Value.absent(), + this.thumbHash = const Value.absent(), + this.deletedAt = const Value.absent(), + this.livePhotoVideoId = const Value.absent(), + required int visibility, + this.stackId = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id), + checksum = Value(checksum), + ownerId = Value(ownerId), + visibility = Value(visibility); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? ownerId, + Expression? localDateTime, + Expression? thumbHash, + Expression? deletedAt, + Expression? livePhotoVideoId, + Expression? visibility, + Expression? stackId, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (ownerId != null) 'owner_id': ownerId, + if (localDateTime != null) 'local_date_time': localDateTime, + if (thumbHash != null) 'thumb_hash': thumbHash, + if (deletedAt != null) 'deleted_at': deletedAt, + if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, + if (visibility != null) 'visibility': visibility, + if (stackId != null) 'stack_id': stackId, + }); + } + + RemoteAssetEntityCompanion copyWith( + {Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? ownerId, + Value? localDateTime, + Value? thumbHash, + Value? deletedAt, + Value? livePhotoVideoId, + Value? visibility, + Value? stackId}) { + return RemoteAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + ownerId: ownerId ?? this.ownerId, + localDateTime: localDateTime ?? this.localDateTime, + thumbHash: thumbHash ?? this.thumbHash, + deletedAt: deletedAt ?? this.deletedAt, + livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, + visibility: visibility ?? this.visibility, + stackId: stackId ?? this.stackId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (localDateTime.present) { + map['local_date_time'] = Variable(localDateTime.value); + } + if (thumbHash.present) { + map['thumb_hash'] = Variable(thumbHash.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (livePhotoVideoId.present) { + map['live_photo_video_id'] = Variable(livePhotoVideoId.value); + } + if (visibility.present) { + map['visibility'] = Variable(visibility.value); + } + if (stackId.present) { + map['stack_id'] = Variable(stackId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('ownerId: $ownerId, ') + ..write('localDateTime: $localDateTime, ') + ..write('thumbHash: $thumbHash, ') + ..write('deletedAt: $deletedAt, ') + ..write('livePhotoVideoId: $livePhotoVideoId, ') + ..write('visibility: $visibility, ') + ..write('stackId: $stackId') + ..write(')')) + .toString(); + } +} + +class LocalAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn durationInSeconds = GeneratedColumn( + 'duration_in_seconds', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn checksum = GeneratedColumn( + 'checksum', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_favorite" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const CustomExpression('0')); + @override + List get $columns => [ + name, + type, + createdAt, + updatedAt, + width, + height, + durationInSeconds, + id, + checksum, + isFavorite, + orientation + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_asset_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAssetEntityData( + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + durationInSeconds: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}duration_in_seconds']), + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + checksum: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}checksum']), + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + orientation: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}orientation'])!, + ); + } + + @override + LocalAssetEntity createAlias(String alias) { + return LocalAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAssetEntityData extends DataClass + implements Insertable { + final String name; + final int type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final String id; + final String? checksum; + final bool isFavorite; + final int orientation; + const LocalAssetEntityData( + {required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.id, + this.checksum, + required this.isFavorite, + required this.orientation}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['type'] = Variable(type); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || durationInSeconds != null) { + map['duration_in_seconds'] = Variable(durationInSeconds); + } + map['id'] = Variable(id); + if (!nullToAbsent || checksum != null) { + map['checksum'] = Variable(checksum); + } + map['is_favorite'] = Variable(isFavorite); + map['orientation'] = Variable(orientation); + return map; + } + + factory LocalAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAssetEntityData( + name: serializer.fromJson(json['name']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + width: serializer.fromJson(json['width']), + height: serializer.fromJson(json['height']), + durationInSeconds: serializer.fromJson(json['durationInSeconds']), + id: serializer.fromJson(json['id']), + checksum: serializer.fromJson(json['checksum']), + isFavorite: serializer.fromJson(json['isFavorite']), + orientation: serializer.fromJson(json['orientation']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'width': serializer.toJson(width), + 'height': serializer.toJson(height), + 'durationInSeconds': serializer.toJson(durationInSeconds), + 'id': serializer.toJson(id), + 'checksum': serializer.toJson(checksum), + 'isFavorite': serializer.toJson(isFavorite), + 'orientation': serializer.toJson(orientation), + }; + } + + LocalAssetEntityData copyWith( + {String? name, + int? type, + DateTime? createdAt, + DateTime? updatedAt, + Value width = const Value.absent(), + Value height = const Value.absent(), + Value durationInSeconds = const Value.absent(), + String? id, + Value checksum = const Value.absent(), + bool? isFavorite, + int? orientation}) => + LocalAssetEntityData( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width.present ? width.value : this.width, + height: height.present ? height.value : this.height, + durationInSeconds: durationInSeconds.present + ? durationInSeconds.value + : this.durationInSeconds, + id: id ?? this.id, + checksum: checksum.present ? checksum.value : this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + LocalAssetEntityData copyWithCompanion(LocalAssetEntityCompanion data) { + return LocalAssetEntityData( + name: data.name.present ? data.name.value : this.name, + type: data.type.present ? data.type.value : this.type, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + width: data.width.present ? data.width.value : this.width, + height: data.height.present ? data.height.value : this.height, + durationInSeconds: data.durationInSeconds.present + ? data.durationInSeconds.value + : this.durationInSeconds, + id: data.id.present ? data.id.value : this.id, + checksum: data.checksum.present ? data.checksum.value : this.checksum, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + orientation: + data.orientation.present ? data.orientation.value : this.orientation, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityData(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, type, createdAt, updatedAt, width, + height, durationInSeconds, id, checksum, isFavorite, orientation); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAssetEntityData && + other.name == this.name && + other.type == this.type && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.width == this.width && + other.height == this.height && + other.durationInSeconds == this.durationInSeconds && + other.id == this.id && + other.checksum == this.checksum && + other.isFavorite == this.isFavorite && + other.orientation == this.orientation); +} + +class LocalAssetEntityCompanion extends UpdateCompanion { + final Value name; + final Value type; + final Value createdAt; + final Value updatedAt; + final Value width; + final Value height; + final Value durationInSeconds; + final Value id; + final Value checksum; + final Value isFavorite; + final Value orientation; + const LocalAssetEntityCompanion({ + this.name = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + this.id = const Value.absent(), + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }); + LocalAssetEntityCompanion.insert({ + required String name, + required int type, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.width = const Value.absent(), + this.height = const Value.absent(), + this.durationInSeconds = const Value.absent(), + required String id, + this.checksum = const Value.absent(), + this.isFavorite = const Value.absent(), + this.orientation = const Value.absent(), + }) : name = Value(name), + type = Value(type), + id = Value(id); + static Insertable custom({ + Expression? name, + Expression? type, + Expression? createdAt, + Expression? updatedAt, + Expression? width, + Expression? height, + Expression? durationInSeconds, + Expression? id, + Expression? checksum, + Expression? isFavorite, + Expression? orientation, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (width != null) 'width': width, + if (height != null) 'height': height, + if (durationInSeconds != null) 'duration_in_seconds': durationInSeconds, + if (id != null) 'id': id, + if (checksum != null) 'checksum': checksum, + if (isFavorite != null) 'is_favorite': isFavorite, + if (orientation != null) 'orientation': orientation, + }); + } + + LocalAssetEntityCompanion copyWith( + {Value? name, + Value? type, + Value? createdAt, + Value? updatedAt, + Value? width, + Value? height, + Value? durationInSeconds, + Value? id, + Value? checksum, + Value? isFavorite, + Value? orientation}) { + return LocalAssetEntityCompanion( + name: name ?? this.name, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + width: width ?? this.width, + height: height ?? this.height, + durationInSeconds: durationInSeconds ?? this.durationInSeconds, + id: id ?? this.id, + checksum: checksum ?? this.checksum, + isFavorite: isFavorite ?? this.isFavorite, + orientation: orientation ?? this.orientation, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (durationInSeconds.present) { + map['duration_in_seconds'] = Variable(durationInSeconds.value); + } + if (id.present) { + map['id'] = Variable(id.value); + } + if (checksum.present) { + map['checksum'] = Variable(checksum.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAssetEntityCompanion(') + ..write('name: $name, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('width: $width, ') + ..write('height: $height, ') + ..write('durationInSeconds: $durationInSeconds, ') + ..write('id: $id, ') + ..write('checksum: $checksum, ') + ..write('isFavorite: $isFavorite, ') + ..write('orientation: $orientation') + ..write(')')) + .toString(); + } +} + +class StackEntity extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + StackEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn primaryAssetId = GeneratedColumn( + 'primary_asset_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => + [id, createdAt, updatedAt, ownerId, primaryAssetId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'stack_entity'; + @override + Set get $primaryKey => {id}; + @override + StackEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return StackEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + primaryAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}primary_asset_id'])!, + ); + } + + @override + StackEntity createAlias(String alias) { + return StackEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class StackEntityData extends DataClass implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String primaryAssetId; + const StackEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.primaryAssetId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['primary_asset_id'] = Variable(primaryAssetId); + return map; + } + + factory StackEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return StackEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + primaryAssetId: serializer.fromJson(json['primaryAssetId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'primaryAssetId': serializer.toJson(primaryAssetId), + }; + } + + StackEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? primaryAssetId}) => + StackEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + StackEntityData copyWithCompanion(StackEntityCompanion data) { + return StackEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + primaryAssetId: data.primaryAssetId.present + ? data.primaryAssetId.value + : this.primaryAssetId, + ); + } + + @override + String toString() { + return (StringBuffer('StackEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, createdAt, updatedAt, ownerId, primaryAssetId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StackEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.primaryAssetId == this.primaryAssetId); +} + +class StackEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value primaryAssetId; + const StackEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.primaryAssetId = const Value.absent(), + }); + StackEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String primaryAssetId, + }) : id = Value(id), + ownerId = Value(ownerId), + primaryAssetId = Value(primaryAssetId); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? primaryAssetId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (primaryAssetId != null) 'primary_asset_id': primaryAssetId, + }); + } + + StackEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? primaryAssetId}) { + return StackEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + primaryAssetId: primaryAssetId ?? this.primaryAssetId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (primaryAssetId.present) { + map['primary_asset_id'] = Variable(primaryAssetId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('StackEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('primaryAssetId: $primaryAssetId') + ..write(')')) + .toString(); + } +} + +class UserMetadataEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + UserMetadataEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn key = GeneratedColumn( + 'key', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn value = GeneratedColumn( + 'value', aliasedName, false, + type: DriftSqlType.blob, requiredDuringInsert: true); + @override + List get $columns => [userId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'user_metadata_entity'; + @override + Set get $primaryKey => {userId, key}; + @override + UserMetadataEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UserMetadataEntityData( + userId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + key: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}key'])!, + value: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}value'])!, + ); + } + + @override + UserMetadataEntity createAlias(String alias) { + return UserMetadataEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class UserMetadataEntityData extends DataClass + implements Insertable { + final String userId; + final int key; + final Uint8List value; + const UserMetadataEntityData( + {required this.userId, required this.key, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['user_id'] = Variable(userId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + factory UserMetadataEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UserMetadataEntityData( + userId: serializer.fromJson(json['userId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + UserMetadataEntityData copyWith( + {String? userId, int? key, Uint8List? value}) => + UserMetadataEntityData( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + UserMetadataEntityData copyWithCompanion(UserMetadataEntityCompanion data) { + return UserMetadataEntityData( + userId: data.userId.present ? data.userId.value : this.userId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityData(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(userId, key, $driftBlobEquality.hash(value)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UserMetadataEntityData && + other.userId == this.userId && + other.key == this.key && + $driftBlobEquality.equals(other.value, this.value)); +} + +class UserMetadataEntityCompanion + extends UpdateCompanion { + final Value userId; + final Value key; + final Value value; + const UserMetadataEntityCompanion({ + this.userId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + }); + UserMetadataEntityCompanion.insert({ + required String userId, + required int key, + required Uint8List value, + }) : userId = Value(userId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? userId, + Expression? key, + Expression? value, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (key != null) 'key': key, + if (value != null) 'value': value, + }); + } + + UserMetadataEntityCompanion copyWith( + {Value? userId, Value? key, Value? value}) { + return UserMetadataEntityCompanion( + userId: userId ?? this.userId, + key: key ?? this.key, + value: value ?? this.value, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UserMetadataEntityCompanion(') + ..write('userId: $userId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } +} + +class PartnerEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PartnerEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn sharedById = GeneratedColumn( + 'shared_by_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn sharedWithId = GeneratedColumn( + 'shared_with_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn inTimeline = GeneratedColumn( + 'in_timeline', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("in_timeline" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + @override + List get $columns => [sharedById, sharedWithId, inTimeline]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'partner_entity'; + @override + Set get $primaryKey => {sharedById, sharedWithId}; + @override + PartnerEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PartnerEntityData( + sharedById: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}shared_by_id'])!, + sharedWithId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}shared_with_id'])!, + inTimeline: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}in_timeline'])!, + ); + } + + @override + PartnerEntity createAlias(String alias) { + return PartnerEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PartnerEntityData extends DataClass + implements Insertable { + final String sharedById; + final String sharedWithId; + final bool inTimeline; + const PartnerEntityData( + {required this.sharedById, + required this.sharedWithId, + required this.inTimeline}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['shared_by_id'] = Variable(sharedById); + map['shared_with_id'] = Variable(sharedWithId); + map['in_timeline'] = Variable(inTimeline); + return map; + } + + factory PartnerEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PartnerEntityData( + sharedById: serializer.fromJson(json['sharedById']), + sharedWithId: serializer.fromJson(json['sharedWithId']), + inTimeline: serializer.fromJson(json['inTimeline']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'sharedById': serializer.toJson(sharedById), + 'sharedWithId': serializer.toJson(sharedWithId), + 'inTimeline': serializer.toJson(inTimeline), + }; + } + + PartnerEntityData copyWith( + {String? sharedById, String? sharedWithId, bool? inTimeline}) => + PartnerEntityData( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + PartnerEntityData copyWithCompanion(PartnerEntityCompanion data) { + return PartnerEntityData( + sharedById: + data.sharedById.present ? data.sharedById.value : this.sharedById, + sharedWithId: data.sharedWithId.present + ? data.sharedWithId.value + : this.sharedWithId, + inTimeline: + data.inTimeline.present ? data.inTimeline.value : this.inTimeline, + ); + } + + @override + String toString() { + return (StringBuffer('PartnerEntityData(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(sharedById, sharedWithId, inTimeline); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartnerEntityData && + other.sharedById == this.sharedById && + other.sharedWithId == this.sharedWithId && + other.inTimeline == this.inTimeline); +} + +class PartnerEntityCompanion extends UpdateCompanion { + final Value sharedById; + final Value sharedWithId; + final Value inTimeline; + const PartnerEntityCompanion({ + this.sharedById = const Value.absent(), + this.sharedWithId = const Value.absent(), + this.inTimeline = const Value.absent(), + }); + PartnerEntityCompanion.insert({ + required String sharedById, + required String sharedWithId, + this.inTimeline = const Value.absent(), + }) : sharedById = Value(sharedById), + sharedWithId = Value(sharedWithId); + static Insertable custom({ + Expression? sharedById, + Expression? sharedWithId, + Expression? inTimeline, + }) { + return RawValuesInsertable({ + if (sharedById != null) 'shared_by_id': sharedById, + if (sharedWithId != null) 'shared_with_id': sharedWithId, + if (inTimeline != null) 'in_timeline': inTimeline, + }); + } + + PartnerEntityCompanion copyWith( + {Value? sharedById, + Value? sharedWithId, + Value? inTimeline}) { + return PartnerEntityCompanion( + sharedById: sharedById ?? this.sharedById, + sharedWithId: sharedWithId ?? this.sharedWithId, + inTimeline: inTimeline ?? this.inTimeline, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (sharedById.present) { + map['shared_by_id'] = Variable(sharedById.value); + } + if (sharedWithId.present) { + map['shared_with_id'] = Variable(sharedWithId.value); + } + if (inTimeline.present) { + map['in_timeline'] = Variable(inTimeline.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PartnerEntityCompanion(') + ..write('sharedById: $sharedById, ') + ..write('sharedWithId: $sharedWithId, ') + ..write('inTimeline: $inTimeline') + ..write(')')) + .toString(); + } +} + +class LocalAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn backupSelection = GeneratedColumn( + 'backup_selection', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn isIosSharedAlbum = GeneratedColumn( + 'is_ios_shared_album', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_ios_shared_album" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn marker_ = GeneratedColumn( + 'marker', aliasedName, true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("marker" IN (0, 1))')); + @override + List get $columns => + [id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_entity'; + @override + Set get $primaryKey => {id}; + @override + LocalAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + backupSelection: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}backup_selection'])!, + isIosSharedAlbum: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_ios_shared_album'])!, + marker_: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}marker']), + ); + } + + @override + LocalAlbumEntity createAlias(String alias) { + return LocalAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final DateTime updatedAt; + final int backupSelection; + final bool isIosSharedAlbum; + final bool? marker_; + const LocalAlbumEntityData( + {required this.id, + required this.name, + required this.updatedAt, + required this.backupSelection, + required this.isIosSharedAlbum, + this.marker_}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['updated_at'] = Variable(updatedAt); + map['backup_selection'] = Variable(backupSelection); + map['is_ios_shared_album'] = Variable(isIosSharedAlbum); + if (!nullToAbsent || marker_ != null) { + map['marker'] = Variable(marker_); + } + return map; + } + + factory LocalAlbumEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + updatedAt: serializer.fromJson(json['updatedAt']), + backupSelection: serializer.fromJson(json['backupSelection']), + isIosSharedAlbum: serializer.fromJson(json['isIosSharedAlbum']), + marker_: serializer.fromJson(json['marker_']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'updatedAt': serializer.toJson(updatedAt), + 'backupSelection': serializer.toJson(backupSelection), + 'isIosSharedAlbum': serializer.toJson(isIosSharedAlbum), + 'marker_': serializer.toJson(marker_), + }; + } + + LocalAlbumEntityData copyWith( + {String? id, + String? name, + DateTime? updatedAt, + int? backupSelection, + bool? isIosSharedAlbum, + Value marker_ = const Value.absent()}) => + LocalAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + marker_: marker_.present ? marker_.value : this.marker_, + ); + LocalAlbumEntityData copyWithCompanion(LocalAlbumEntityCompanion data) { + return LocalAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + backupSelection: data.backupSelection.present + ? data.backupSelection.value + : this.backupSelection, + isIosSharedAlbum: data.isIosSharedAlbum.present + ? data.isIosSharedAlbum.value + : this.isIosSharedAlbum, + marker_: data.marker_.present ? data.marker_.value : this.marker_, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, name, updatedAt, backupSelection, isIosSharedAlbum, marker_); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.updatedAt == this.updatedAt && + other.backupSelection == this.backupSelection && + other.isIosSharedAlbum == this.isIosSharedAlbum && + other.marker_ == this.marker_); +} + +class LocalAlbumEntityCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value updatedAt; + final Value backupSelection; + final Value isIosSharedAlbum; + final Value marker_; + const LocalAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.updatedAt = const Value.absent(), + this.backupSelection = const Value.absent(), + this.isIosSharedAlbum = const Value.absent(), + this.marker_ = const Value.absent(), + }); + LocalAlbumEntityCompanion.insert({ + required String id, + required String name, + this.updatedAt = const Value.absent(), + required int backupSelection, + this.isIosSharedAlbum = const Value.absent(), + this.marker_ = const Value.absent(), + }) : id = Value(id), + name = Value(name), + backupSelection = Value(backupSelection); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? updatedAt, + Expression? backupSelection, + Expression? isIosSharedAlbum, + Expression? marker_, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (updatedAt != null) 'updated_at': updatedAt, + if (backupSelection != null) 'backup_selection': backupSelection, + if (isIosSharedAlbum != null) 'is_ios_shared_album': isIosSharedAlbum, + if (marker_ != null) 'marker': marker_, + }); + } + + LocalAlbumEntityCompanion copyWith( + {Value? id, + Value? name, + Value? updatedAt, + Value? backupSelection, + Value? isIosSharedAlbum, + Value? marker_}) { + return LocalAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + updatedAt: updatedAt ?? this.updatedAt, + backupSelection: backupSelection ?? this.backupSelection, + isIosSharedAlbum: isIosSharedAlbum ?? this.isIosSharedAlbum, + marker_: marker_ ?? this.marker_, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (backupSelection.present) { + map['backup_selection'] = Variable(backupSelection.value); + } + if (isIosSharedAlbum.present) { + map['is_ios_shared_album'] = Variable(isIosSharedAlbum.value); + } + if (marker_.present) { + map['marker'] = Variable(marker_.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('updatedAt: $updatedAt, ') + ..write('backupSelection: $backupSelection, ') + ..write('isIosSharedAlbum: $isIosSharedAlbum, ') + ..write('marker_: $marker_') + ..write(')')) + .toString(); + } +} + +class LocalAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + LocalAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES local_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'local_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + LocalAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LocalAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + LocalAlbumAssetEntity createAlias(String alias) { + return LocalAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class LocalAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const LocalAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory LocalAlbumAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocalAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + LocalAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + LocalAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + LocalAlbumAssetEntityData copyWithCompanion( + LocalAlbumAssetEntityCompanion data) { + return LocalAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocalAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class LocalAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const LocalAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + LocalAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + LocalAlbumAssetEntityCompanion copyWith( + {Value? assetId, Value? albumId}) { + return LocalAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocalAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteExifEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteExifEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn city = GeneratedColumn( + 'city', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn state = GeneratedColumn( + 'state', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn country = GeneratedColumn( + 'country', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn dateTimeOriginal = + GeneratedColumn('date_time_original', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn height = GeneratedColumn( + 'height', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn width = GeneratedColumn( + 'width', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn exposureTime = GeneratedColumn( + 'exposure_time', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn fNumber = GeneratedColumn( + 'f_number', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn fileSize = GeneratedColumn( + 'file_size', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn focalLength = GeneratedColumn( + 'focal_length', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', aliasedName, true, + type: DriftSqlType.double, requiredDuringInsert: false); + late final GeneratedColumn iso = GeneratedColumn( + 'iso', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn make = GeneratedColumn( + 'make', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn model = GeneratedColumn( + 'model', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn lens = GeneratedColumn( + 'lens', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn orientation = GeneratedColumn( + 'orientation', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn timeZone = GeneratedColumn( + 'time_zone', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn rating = GeneratedColumn( + 'rating', aliasedName, true, + type: DriftSqlType.int, requiredDuringInsert: false); + late final GeneratedColumn projectionType = GeneratedColumn( + 'projection_type', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + @override + List get $columns => [ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_exif_entity'; + @override + Set get $primaryKey => {assetId}; + @override + RemoteExifEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteExifEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + city: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}city']), + state: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}state']), + country: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}country']), + dateTimeOriginal: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, data['${effectivePrefix}date_time_original']), + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description']), + height: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}height']), + width: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}width']), + exposureTime: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}exposure_time']), + fNumber: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}f_number']), + fileSize: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}file_size']), + focalLength: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}focal_length']), + latitude: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}latitude']), + longitude: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}longitude']), + iso: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}iso']), + make: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}make']), + model: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}model']), + lens: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}lens']), + orientation: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}orientation']), + timeZone: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}time_zone']), + rating: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}rating']), + projectionType: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}projection_type']), + ); + } + + @override + RemoteExifEntity createAlias(String alias) { + return RemoteExifEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteExifEntityData extends DataClass + implements Insertable { + final String assetId; + final String? city; + final String? state; + final String? country; + final DateTime? dateTimeOriginal; + final String? description; + final int? height; + final int? width; + final String? exposureTime; + final double? fNumber; + final int? fileSize; + final double? focalLength; + final double? latitude; + final double? longitude; + final int? iso; + final String? make; + final String? model; + final String? lens; + final String? orientation; + final String? timeZone; + final int? rating; + final String? projectionType; + const RemoteExifEntityData( + {required this.assetId, + this.city, + this.state, + this.country, + this.dateTimeOriginal, + this.description, + this.height, + this.width, + this.exposureTime, + this.fNumber, + this.fileSize, + this.focalLength, + this.latitude, + this.longitude, + this.iso, + this.make, + this.model, + this.lens, + this.orientation, + this.timeZone, + this.rating, + this.projectionType}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + if (!nullToAbsent || city != null) { + map['city'] = Variable(city); + } + if (!nullToAbsent || state != null) { + map['state'] = Variable(state); + } + if (!nullToAbsent || country != null) { + map['country'] = Variable(country); + } + if (!nullToAbsent || dateTimeOriginal != null) { + map['date_time_original'] = Variable(dateTimeOriginal); + } + if (!nullToAbsent || description != null) { + map['description'] = Variable(description); + } + if (!nullToAbsent || height != null) { + map['height'] = Variable(height); + } + if (!nullToAbsent || width != null) { + map['width'] = Variable(width); + } + if (!nullToAbsent || exposureTime != null) { + map['exposure_time'] = Variable(exposureTime); + } + if (!nullToAbsent || fNumber != null) { + map['f_number'] = Variable(fNumber); + } + if (!nullToAbsent || fileSize != null) { + map['file_size'] = Variable(fileSize); + } + if (!nullToAbsent || focalLength != null) { + map['focal_length'] = Variable(focalLength); + } + if (!nullToAbsent || latitude != null) { + map['latitude'] = Variable(latitude); + } + if (!nullToAbsent || longitude != null) { + map['longitude'] = Variable(longitude); + } + if (!nullToAbsent || iso != null) { + map['iso'] = Variable(iso); + } + if (!nullToAbsent || make != null) { + map['make'] = Variable(make); + } + if (!nullToAbsent || model != null) { + map['model'] = Variable(model); + } + if (!nullToAbsent || lens != null) { + map['lens'] = Variable(lens); + } + if (!nullToAbsent || orientation != null) { + map['orientation'] = Variable(orientation); + } + if (!nullToAbsent || timeZone != null) { + map['time_zone'] = Variable(timeZone); + } + if (!nullToAbsent || rating != null) { + map['rating'] = Variable(rating); + } + if (!nullToAbsent || projectionType != null) { + map['projection_type'] = Variable(projectionType); + } + return map; + } + + factory RemoteExifEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteExifEntityData( + assetId: serializer.fromJson(json['assetId']), + city: serializer.fromJson(json['city']), + state: serializer.fromJson(json['state']), + country: serializer.fromJson(json['country']), + dateTimeOriginal: + serializer.fromJson(json['dateTimeOriginal']), + description: serializer.fromJson(json['description']), + height: serializer.fromJson(json['height']), + width: serializer.fromJson(json['width']), + exposureTime: serializer.fromJson(json['exposureTime']), + fNumber: serializer.fromJson(json['fNumber']), + fileSize: serializer.fromJson(json['fileSize']), + focalLength: serializer.fromJson(json['focalLength']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + iso: serializer.fromJson(json['iso']), + make: serializer.fromJson(json['make']), + model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), + orientation: serializer.fromJson(json['orientation']), + timeZone: serializer.fromJson(json['timeZone']), + rating: serializer.fromJson(json['rating']), + projectionType: serializer.fromJson(json['projectionType']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'city': serializer.toJson(city), + 'state': serializer.toJson(state), + 'country': serializer.toJson(country), + 'dateTimeOriginal': serializer.toJson(dateTimeOriginal), + 'description': serializer.toJson(description), + 'height': serializer.toJson(height), + 'width': serializer.toJson(width), + 'exposureTime': serializer.toJson(exposureTime), + 'fNumber': serializer.toJson(fNumber), + 'fileSize': serializer.toJson(fileSize), + 'focalLength': serializer.toJson(focalLength), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'iso': serializer.toJson(iso), + 'make': serializer.toJson(make), + 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), + 'orientation': serializer.toJson(orientation), + 'timeZone': serializer.toJson(timeZone), + 'rating': serializer.toJson(rating), + 'projectionType': serializer.toJson(projectionType), + }; + } + + RemoteExifEntityData copyWith( + {String? assetId, + Value city = const Value.absent(), + Value state = const Value.absent(), + Value country = const Value.absent(), + Value dateTimeOriginal = const Value.absent(), + Value description = const Value.absent(), + Value height = const Value.absent(), + Value width = const Value.absent(), + Value exposureTime = const Value.absent(), + Value fNumber = const Value.absent(), + Value fileSize = const Value.absent(), + Value focalLength = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value iso = const Value.absent(), + Value make = const Value.absent(), + Value model = const Value.absent(), + Value lens = const Value.absent(), + Value orientation = const Value.absent(), + Value timeZone = const Value.absent(), + Value rating = const Value.absent(), + Value projectionType = const Value.absent()}) => + RemoteExifEntityData( + assetId: assetId ?? this.assetId, + city: city.present ? city.value : this.city, + state: state.present ? state.value : this.state, + country: country.present ? country.value : this.country, + dateTimeOriginal: dateTimeOriginal.present + ? dateTimeOriginal.value + : this.dateTimeOriginal, + description: description.present ? description.value : this.description, + height: height.present ? height.value : this.height, + width: width.present ? width.value : this.width, + exposureTime: + exposureTime.present ? exposureTime.value : this.exposureTime, + fNumber: fNumber.present ? fNumber.value : this.fNumber, + fileSize: fileSize.present ? fileSize.value : this.fileSize, + focalLength: focalLength.present ? focalLength.value : this.focalLength, + latitude: latitude.present ? latitude.value : this.latitude, + longitude: longitude.present ? longitude.value : this.longitude, + iso: iso.present ? iso.value : this.iso, + make: make.present ? make.value : this.make, + model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, + orientation: orientation.present ? orientation.value : this.orientation, + timeZone: timeZone.present ? timeZone.value : this.timeZone, + rating: rating.present ? rating.value : this.rating, + projectionType: + projectionType.present ? projectionType.value : this.projectionType, + ); + RemoteExifEntityData copyWithCompanion(RemoteExifEntityCompanion data) { + return RemoteExifEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + city: data.city.present ? data.city.value : this.city, + state: data.state.present ? data.state.value : this.state, + country: data.country.present ? data.country.value : this.country, + dateTimeOriginal: data.dateTimeOriginal.present + ? data.dateTimeOriginal.value + : this.dateTimeOriginal, + description: + data.description.present ? data.description.value : this.description, + height: data.height.present ? data.height.value : this.height, + width: data.width.present ? data.width.value : this.width, + exposureTime: data.exposureTime.present + ? data.exposureTime.value + : this.exposureTime, + fNumber: data.fNumber.present ? data.fNumber.value : this.fNumber, + fileSize: data.fileSize.present ? data.fileSize.value : this.fileSize, + focalLength: + data.focalLength.present ? data.focalLength.value : this.focalLength, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + iso: data.iso.present ? data.iso.value : this.iso, + make: data.make.present ? data.make.value : this.make, + model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, + orientation: + data.orientation.present ? data.orientation.value : this.orientation, + timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, + rating: data.rating.present ? data.rating.value : this.rating, + projectionType: data.projectionType.present + ? data.projectionType.value + : this.projectionType, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityData(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + assetId, + city, + state, + country, + dateTimeOriginal, + description, + height, + width, + exposureTime, + fNumber, + fileSize, + focalLength, + latitude, + longitude, + iso, + make, + model, + lens, + orientation, + timeZone, + rating, + projectionType + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteExifEntityData && + other.assetId == this.assetId && + other.city == this.city && + other.state == this.state && + other.country == this.country && + other.dateTimeOriginal == this.dateTimeOriginal && + other.description == this.description && + other.height == this.height && + other.width == this.width && + other.exposureTime == this.exposureTime && + other.fNumber == this.fNumber && + other.fileSize == this.fileSize && + other.focalLength == this.focalLength && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.iso == this.iso && + other.make == this.make && + other.model == this.model && + other.lens == this.lens && + other.orientation == this.orientation && + other.timeZone == this.timeZone && + other.rating == this.rating && + other.projectionType == this.projectionType); +} + +class RemoteExifEntityCompanion extends UpdateCompanion { + final Value assetId; + final Value city; + final Value state; + final Value country; + final Value dateTimeOriginal; + final Value description; + final Value height; + final Value width; + final Value exposureTime; + final Value fNumber; + final Value fileSize; + final Value focalLength; + final Value latitude; + final Value longitude; + final Value iso; + final Value make; + final Value model; + final Value lens; + final Value orientation; + final Value timeZone; + final Value rating; + final Value projectionType; + const RemoteExifEntityCompanion({ + this.assetId = const Value.absent(), + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }); + RemoteExifEntityCompanion.insert({ + required String assetId, + this.city = const Value.absent(), + this.state = const Value.absent(), + this.country = const Value.absent(), + this.dateTimeOriginal = const Value.absent(), + this.description = const Value.absent(), + this.height = const Value.absent(), + this.width = const Value.absent(), + this.exposureTime = const Value.absent(), + this.fNumber = const Value.absent(), + this.fileSize = const Value.absent(), + this.focalLength = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.iso = const Value.absent(), + this.make = const Value.absent(), + this.model = const Value.absent(), + this.lens = const Value.absent(), + this.orientation = const Value.absent(), + this.timeZone = const Value.absent(), + this.rating = const Value.absent(), + this.projectionType = const Value.absent(), + }) : assetId = Value(assetId); + static Insertable custom({ + Expression? assetId, + Expression? city, + Expression? state, + Expression? country, + Expression? dateTimeOriginal, + Expression? description, + Expression? height, + Expression? width, + Expression? exposureTime, + Expression? fNumber, + Expression? fileSize, + Expression? focalLength, + Expression? latitude, + Expression? longitude, + Expression? iso, + Expression? make, + Expression? model, + Expression? lens, + Expression? orientation, + Expression? timeZone, + Expression? rating, + Expression? projectionType, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (city != null) 'city': city, + if (state != null) 'state': state, + if (country != null) 'country': country, + if (dateTimeOriginal != null) 'date_time_original': dateTimeOriginal, + if (description != null) 'description': description, + if (height != null) 'height': height, + if (width != null) 'width': width, + if (exposureTime != null) 'exposure_time': exposureTime, + if (fNumber != null) 'f_number': fNumber, + if (fileSize != null) 'file_size': fileSize, + if (focalLength != null) 'focal_length': focalLength, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (iso != null) 'iso': iso, + if (make != null) 'make': make, + if (model != null) 'model': model, + if (lens != null) 'lens': lens, + if (orientation != null) 'orientation': orientation, + if (timeZone != null) 'time_zone': timeZone, + if (rating != null) 'rating': rating, + if (projectionType != null) 'projection_type': projectionType, + }); + } + + RemoteExifEntityCompanion copyWith( + {Value? assetId, + Value? city, + Value? state, + Value? country, + Value? dateTimeOriginal, + Value? description, + Value? height, + Value? width, + Value? exposureTime, + Value? fNumber, + Value? fileSize, + Value? focalLength, + Value? latitude, + Value? longitude, + Value? iso, + Value? make, + Value? model, + Value? lens, + Value? orientation, + Value? timeZone, + Value? rating, + Value? projectionType}) { + return RemoteExifEntityCompanion( + assetId: assetId ?? this.assetId, + city: city ?? this.city, + state: state ?? this.state, + country: country ?? this.country, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + description: description ?? this.description, + height: height ?? this.height, + width: width ?? this.width, + exposureTime: exposureTime ?? this.exposureTime, + fNumber: fNumber ?? this.fNumber, + fileSize: fileSize ?? this.fileSize, + focalLength: focalLength ?? this.focalLength, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + iso: iso ?? this.iso, + make: make ?? this.make, + model: model ?? this.model, + lens: lens ?? this.lens, + orientation: orientation ?? this.orientation, + timeZone: timeZone ?? this.timeZone, + rating: rating ?? this.rating, + projectionType: projectionType ?? this.projectionType, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (city.present) { + map['city'] = Variable(city.value); + } + if (state.present) { + map['state'] = Variable(state.value); + } + if (country.present) { + map['country'] = Variable(country.value); + } + if (dateTimeOriginal.present) { + map['date_time_original'] = Variable(dateTimeOriginal.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (height.present) { + map['height'] = Variable(height.value); + } + if (width.present) { + map['width'] = Variable(width.value); + } + if (exposureTime.present) { + map['exposure_time'] = Variable(exposureTime.value); + } + if (fNumber.present) { + map['f_number'] = Variable(fNumber.value); + } + if (fileSize.present) { + map['file_size'] = Variable(fileSize.value); + } + if (focalLength.present) { + map['focal_length'] = Variable(focalLength.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (iso.present) { + map['iso'] = Variable(iso.value); + } + if (make.present) { + map['make'] = Variable(make.value); + } + if (model.present) { + map['model'] = Variable(model.value); + } + if (lens.present) { + map['lens'] = Variable(lens.value); + } + if (orientation.present) { + map['orientation'] = Variable(orientation.value); + } + if (timeZone.present) { + map['time_zone'] = Variable(timeZone.value); + } + if (rating.present) { + map['rating'] = Variable(rating.value); + } + if (projectionType.present) { + map['projection_type'] = Variable(projectionType.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteExifEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('city: $city, ') + ..write('state: $state, ') + ..write('country: $country, ') + ..write('dateTimeOriginal: $dateTimeOriginal, ') + ..write('description: $description, ') + ..write('height: $height, ') + ..write('width: $width, ') + ..write('exposureTime: $exposureTime, ') + ..write('fNumber: $fNumber, ') + ..write('fileSize: $fileSize, ') + ..write('focalLength: $focalLength, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('iso: $iso, ') + ..write('make: $make, ') + ..write('model: $model, ') + ..write('lens: $lens, ') + ..write('orientation: $orientation, ') + ..write('timeZone: $timeZone, ') + ..write('rating: $rating, ') + ..write('projectionType: $projectionType') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn description = GeneratedColumn( + 'description', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const CustomExpression('\'\'')); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn thumbnailAssetId = GeneratedColumn( + 'thumbnail_asset_id', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE SET NULL')); + late final GeneratedColumn isActivityEnabled = GeneratedColumn( + 'is_activity_enabled', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_activity_enabled" IN (0, 1))'), + defaultValue: const CustomExpression('1')); + late final GeneratedColumn order = GeneratedColumn( + 'order', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [ + id, + name, + description, + createdAt, + updatedAt, + ownerId, + thumbnailAssetId, + isActivityEnabled, + order + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_entity'; + @override + Set get $primaryKey => {id}; + @override + RemoteAlbumEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + description: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}description'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + thumbnailAssetId: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}thumbnail_asset_id']), + isActivityEnabled: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}is_activity_enabled'])!, + order: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}order'])!, + ); + } + + @override + RemoteAlbumEntity createAlias(String alias) { + return RemoteAlbumEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumEntityData extends DataClass + implements Insertable { + final String id; + final String name; + final String description; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String? thumbnailAssetId; + final bool isActivityEnabled; + final int order; + const RemoteAlbumEntityData( + {required this.id, + required this.name, + required this.description, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + this.thumbnailAssetId, + required this.isActivityEnabled, + required this.order}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + map['description'] = Variable(description); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + if (!nullToAbsent || thumbnailAssetId != null) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId); + } + map['is_activity_enabled'] = Variable(isActivityEnabled); + map['order'] = Variable(order); + return map; + } + + factory RemoteAlbumEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumEntityData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + description: serializer.fromJson(json['description']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + thumbnailAssetId: serializer.fromJson(json['thumbnailAssetId']), + isActivityEnabled: serializer.fromJson(json['isActivityEnabled']), + order: serializer.fromJson(json['order']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'description': serializer.toJson(description), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'thumbnailAssetId': serializer.toJson(thumbnailAssetId), + 'isActivityEnabled': serializer.toJson(isActivityEnabled), + 'order': serializer.toJson(order), + }; + } + + RemoteAlbumEntityData copyWith( + {String? id, + String? name, + String? description, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + Value thumbnailAssetId = const Value.absent(), + bool? isActivityEnabled, + int? order}) => + RemoteAlbumEntityData( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId.present + ? thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + RemoteAlbumEntityData copyWithCompanion(RemoteAlbumEntityCompanion data) { + return RemoteAlbumEntityData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + description: + data.description.present ? data.description.value : this.description, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + thumbnailAssetId: data.thumbnailAssetId.present + ? data.thumbnailAssetId.value + : this.thumbnailAssetId, + isActivityEnabled: data.isActivityEnabled.present + ? data.isActivityEnabled.value + : this.isActivityEnabled, + order: data.order.present ? data.order.value : this.order, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, description, createdAt, updatedAt, + ownerId, thumbnailAssetId, isActivityEnabled, order); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumEntityData && + other.id == this.id && + other.name == this.name && + other.description == this.description && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.thumbnailAssetId == this.thumbnailAssetId && + other.isActivityEnabled == this.isActivityEnabled && + other.order == this.order); +} + +class RemoteAlbumEntityCompanion + extends UpdateCompanion { + final Value id; + final Value name; + final Value description; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value thumbnailAssetId; + final Value isActivityEnabled; + final Value order; + const RemoteAlbumEntityCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + this.order = const Value.absent(), + }); + RemoteAlbumEntityCompanion.insert({ + required String id, + required String name, + this.description = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + this.thumbnailAssetId = const Value.absent(), + this.isActivityEnabled = const Value.absent(), + required int order, + }) : id = Value(id), + name = Value(name), + ownerId = Value(ownerId), + order = Value(order); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? description, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? thumbnailAssetId, + Expression? isActivityEnabled, + Expression? order, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (description != null) 'description': description, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (thumbnailAssetId != null) 'thumbnail_asset_id': thumbnailAssetId, + if (isActivityEnabled != null) 'is_activity_enabled': isActivityEnabled, + if (order != null) 'order': order, + }); + } + + RemoteAlbumEntityCompanion copyWith( + {Value? id, + Value? name, + Value? description, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? thumbnailAssetId, + Value? isActivityEnabled, + Value? order}) { + return RemoteAlbumEntityCompanion( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + thumbnailAssetId: thumbnailAssetId ?? this.thumbnailAssetId, + isActivityEnabled: isActivityEnabled ?? this.isActivityEnabled, + order: order ?? this.order, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (description.present) { + map['description'] = Variable(description.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (thumbnailAssetId.present) { + map['thumbnail_asset_id'] = Variable(thumbnailAssetId.value); + } + if (isActivityEnabled.present) { + map['is_activity_enabled'] = Variable(isActivityEnabled.value); + } + if (order.present) { + map['order'] = Variable(order.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumEntityCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('description: $description, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('thumbnailAssetId: $thumbnailAssetId, ') + ..write('isActivityEnabled: $isActivityEnabled, ') + ..write('order: $order') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, albumId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_asset_entity'; + @override + Set get $primaryKey => {assetId, albumId}; + @override + RemoteAlbumAssetEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + ); + } + + @override + RemoteAlbumAssetEntity createAlias(String alias) { + return RemoteAlbumAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String albumId; + const RemoteAlbumAssetEntityData( + {required this.assetId, required this.albumId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['album_id'] = Variable(albumId); + return map; + } + + factory RemoteAlbumAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + albumId: serializer.fromJson(json['albumId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'albumId': serializer.toJson(albumId), + }; + } + + RemoteAlbumAssetEntityData copyWith({String? assetId, String? albumId}) => + RemoteAlbumAssetEntityData( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + RemoteAlbumAssetEntityData copyWithCompanion( + RemoteAlbumAssetEntityCompanion data) { + return RemoteAlbumAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, albumId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumAssetEntityData && + other.assetId == this.assetId && + other.albumId == this.albumId); +} + +class RemoteAlbumAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value albumId; + const RemoteAlbumAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.albumId = const Value.absent(), + }); + RemoteAlbumAssetEntityCompanion.insert({ + required String assetId, + required String albumId, + }) : assetId = Value(assetId), + albumId = Value(albumId); + static Insertable custom({ + Expression? assetId, + Expression? albumId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (albumId != null) 'album_id': albumId, + }); + } + + RemoteAlbumAssetEntityCompanion copyWith( + {Value? assetId, Value? albumId}) { + return RemoteAlbumAssetEntityCompanion( + assetId: assetId ?? this.assetId, + albumId: albumId ?? this.albumId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('albumId: $albumId') + ..write(')')) + .toString(); + } +} + +class RemoteAlbumUserEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + RemoteAlbumUserEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_album_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn role = GeneratedColumn( + 'role', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [albumId, userId, role]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_album_user_entity'; + @override + Set get $primaryKey => {albumId, userId}; + @override + RemoteAlbumUserEntityData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteAlbumUserEntityData( + albumId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}album_id'])!, + userId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + role: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}role'])!, + ); + } + + @override + RemoteAlbumUserEntity createAlias(String alias) { + return RemoteAlbumUserEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class RemoteAlbumUserEntityData extends DataClass + implements Insertable { + final String albumId; + final String userId; + final int role; + const RemoteAlbumUserEntityData( + {required this.albumId, required this.userId, required this.role}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['album_id'] = Variable(albumId); + map['user_id'] = Variable(userId); + map['role'] = Variable(role); + return map; + } + + factory RemoteAlbumUserEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteAlbumUserEntityData( + albumId: serializer.fromJson(json['albumId']), + userId: serializer.fromJson(json['userId']), + role: serializer.fromJson(json['role']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'albumId': serializer.toJson(albumId), + 'userId': serializer.toJson(userId), + 'role': serializer.toJson(role), + }; + } + + RemoteAlbumUserEntityData copyWith( + {String? albumId, String? userId, int? role}) => + RemoteAlbumUserEntityData( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + RemoteAlbumUserEntityData copyWithCompanion( + RemoteAlbumUserEntityCompanion data) { + return RemoteAlbumUserEntityData( + albumId: data.albumId.present ? data.albumId.value : this.albumId, + userId: data.userId.present ? data.userId.value : this.userId, + role: data.role.present ? data.role.value : this.role, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityData(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(albumId, userId, role); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteAlbumUserEntityData && + other.albumId == this.albumId && + other.userId == this.userId && + other.role == this.role); +} + +class RemoteAlbumUserEntityCompanion + extends UpdateCompanion { + final Value albumId; + final Value userId; + final Value role; + const RemoteAlbumUserEntityCompanion({ + this.albumId = const Value.absent(), + this.userId = const Value.absent(), + this.role = const Value.absent(), + }); + RemoteAlbumUserEntityCompanion.insert({ + required String albumId, + required String userId, + required int role, + }) : albumId = Value(albumId), + userId = Value(userId), + role = Value(role); + static Insertable custom({ + Expression? albumId, + Expression? userId, + Expression? role, + }) { + return RawValuesInsertable({ + if (albumId != null) 'album_id': albumId, + if (userId != null) 'user_id': userId, + if (role != null) 'role': role, + }); + } + + RemoteAlbumUserEntityCompanion copyWith( + {Value? albumId, Value? userId, Value? role}) { + return RemoteAlbumUserEntityCompanion( + albumId: albumId ?? this.albumId, + userId: userId ?? this.userId, + role: role ?? this.role, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteAlbumUserEntityCompanion(') + ..write('albumId: $albumId, ') + ..write('userId: $userId, ') + ..write('role: $role') + ..write(')')) + .toString(); + } +} + +class MemoryEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn deletedAt = GeneratedColumn( + 'deleted_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn type = GeneratedColumn( + 'type', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + late final GeneratedColumn data = GeneratedColumn( + 'data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isSaved = GeneratedColumn( + 'is_saved', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_saved" IN (0, 1))'), + defaultValue: const CustomExpression('0')); + late final GeneratedColumn memoryAt = GeneratedColumn( + 'memory_at', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + late final GeneratedColumn seenAt = GeneratedColumn( + 'seen_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn showAt = GeneratedColumn( + 'show_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn hideAt = GeneratedColumn( + 'hide_at', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + deletedAt, + ownerId, + type, + data, + isSaved, + memoryAt, + seenAt, + showAt, + hideAt + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_entity'; + @override + Set get $primaryKey => {id}; + @override + MemoryEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + deletedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + type: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}type'])!, + data: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!, + isSaved: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_saved'])!, + memoryAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}memory_at'])!, + seenAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}seen_at']), + showAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}show_at']), + hideAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}hide_at']), + ); + } + + @override + MemoryEntity createAlias(String alias) { + return MemoryEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String ownerId; + final int type; + final String data; + final bool isSaved; + final DateTime memoryAt; + final DateTime? seenAt; + final DateTime? showAt; + final DateTime? hideAt; + const MemoryEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.ownerId, + required this.type, + required this.data, + required this.isSaved, + required this.memoryAt, + this.seenAt, + this.showAt, + this.hideAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + map['owner_id'] = Variable(ownerId); + map['type'] = Variable(type); + map['data'] = Variable(data); + map['is_saved'] = Variable(isSaved); + map['memory_at'] = Variable(memoryAt); + if (!nullToAbsent || seenAt != null) { + map['seen_at'] = Variable(seenAt); + } + if (!nullToAbsent || showAt != null) { + map['show_at'] = Variable(showAt); + } + if (!nullToAbsent || hideAt != null) { + map['hide_at'] = Variable(hideAt); + } + return map; + } + + factory MemoryEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + ownerId: serializer.fromJson(json['ownerId']), + type: serializer.fromJson(json['type']), + data: serializer.fromJson(json['data']), + isSaved: serializer.fromJson(json['isSaved']), + memoryAt: serializer.fromJson(json['memoryAt']), + seenAt: serializer.fromJson(json['seenAt']), + showAt: serializer.fromJson(json['showAt']), + hideAt: serializer.fromJson(json['hideAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'ownerId': serializer.toJson(ownerId), + 'type': serializer.toJson(type), + 'data': serializer.toJson(data), + 'isSaved': serializer.toJson(isSaved), + 'memoryAt': serializer.toJson(memoryAt), + 'seenAt': serializer.toJson(seenAt), + 'showAt': serializer.toJson(showAt), + 'hideAt': serializer.toJson(hideAt), + }; + } + + MemoryEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + String? ownerId, + int? type, + String? data, + bool? isSaved, + DateTime? memoryAt, + Value seenAt = const Value.absent(), + Value showAt = const Value.absent(), + Value hideAt = const Value.absent()}) => + MemoryEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt.present ? seenAt.value : this.seenAt, + showAt: showAt.present ? showAt.value : this.showAt, + hideAt: hideAt.present ? hideAt.value : this.hideAt, + ); + MemoryEntityData copyWithCompanion(MemoryEntityCompanion data) { + return MemoryEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + type: data.type.present ? data.type.value : this.type, + data: data.data.present ? data.data.value : this.data, + isSaved: data.isSaved.present ? data.isSaved.value : this.isSaved, + memoryAt: data.memoryAt.present ? data.memoryAt.value : this.memoryAt, + seenAt: data.seenAt.present ? data.seenAt.value : this.seenAt, + showAt: data.showAt.present ? data.showAt.value : this.showAt, + hideAt: data.hideAt.present ? data.hideAt.value : this.hideAt, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, deletedAt, ownerId, + type, data, isSaved, memoryAt, seenAt, showAt, hideAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.ownerId == this.ownerId && + other.type == this.type && + other.data == this.data && + other.isSaved == this.isSaved && + other.memoryAt == this.memoryAt && + other.seenAt == this.seenAt && + other.showAt == this.showAt && + other.hideAt == this.hideAt); +} + +class MemoryEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value ownerId; + final Value type; + final Value data; + final Value isSaved; + final Value memoryAt; + final Value seenAt; + final Value showAt; + final Value hideAt; + const MemoryEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.type = const Value.absent(), + this.data = const Value.absent(), + this.isSaved = const Value.absent(), + this.memoryAt = const Value.absent(), + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }); + MemoryEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + required String ownerId, + required int type, + required String data, + this.isSaved = const Value.absent(), + required DateTime memoryAt, + this.seenAt = const Value.absent(), + this.showAt = const Value.absent(), + this.hideAt = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + type = Value(type), + data = Value(data), + memoryAt = Value(memoryAt); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? deletedAt, + Expression? ownerId, + Expression? type, + Expression? data, + Expression? isSaved, + Expression? memoryAt, + Expression? seenAt, + Expression? showAt, + Expression? hideAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (ownerId != null) 'owner_id': ownerId, + if (type != null) 'type': type, + if (data != null) 'data': data, + if (isSaved != null) 'is_saved': isSaved, + if (memoryAt != null) 'memory_at': memoryAt, + if (seenAt != null) 'seen_at': seenAt, + if (showAt != null) 'show_at': showAt, + if (hideAt != null) 'hide_at': hideAt, + }); + } + + MemoryEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? ownerId, + Value? type, + Value? data, + Value? isSaved, + Value? memoryAt, + Value? seenAt, + Value? showAt, + Value? hideAt}) { + return MemoryEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + ownerId: ownerId ?? this.ownerId, + type: type ?? this.type, + data: data ?? this.data, + isSaved: isSaved ?? this.isSaved, + memoryAt: memoryAt ?? this.memoryAt, + seenAt: seenAt ?? this.seenAt, + showAt: showAt ?? this.showAt, + hideAt: hideAt ?? this.hideAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (data.present) { + map['data'] = Variable(data.value); + } + if (isSaved.present) { + map['is_saved'] = Variable(isSaved.value); + } + if (memoryAt.present) { + map['memory_at'] = Variable(memoryAt.value); + } + if (seenAt.present) { + map['seen_at'] = Variable(seenAt.value); + } + if (showAt.present) { + map['show_at'] = Variable(showAt.value); + } + if (hideAt.present) { + map['hide_at'] = Variable(hideAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('ownerId: $ownerId, ') + ..write('type: $type, ') + ..write('data: $data, ') + ..write('isSaved: $isSaved, ') + ..write('memoryAt: $memoryAt, ') + ..write('seenAt: $seenAt, ') + ..write('showAt: $showAt, ') + ..write('hideAt: $hideAt') + ..write(')')) + .toString(); + } +} + +class MemoryAssetEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + MemoryAssetEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn assetId = GeneratedColumn( + 'asset_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES remote_asset_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn memoryId = GeneratedColumn( + 'memory_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES memory_entity (id) ON DELETE CASCADE')); + @override + List get $columns => [assetId, memoryId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'memory_asset_entity'; + @override + Set get $primaryKey => {assetId, memoryId}; + @override + MemoryAssetEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return MemoryAssetEntityData( + assetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}asset_id'])!, + memoryId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}memory_id'])!, + ); + } + + @override + MemoryAssetEntity createAlias(String alias) { + return MemoryAssetEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class MemoryAssetEntityData extends DataClass + implements Insertable { + final String assetId; + final String memoryId; + const MemoryAssetEntityData({required this.assetId, required this.memoryId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['asset_id'] = Variable(assetId); + map['memory_id'] = Variable(memoryId); + return map; + } + + factory MemoryAssetEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return MemoryAssetEntityData( + assetId: serializer.fromJson(json['assetId']), + memoryId: serializer.fromJson(json['memoryId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'assetId': serializer.toJson(assetId), + 'memoryId': serializer.toJson(memoryId), + }; + } + + MemoryAssetEntityData copyWith({String? assetId, String? memoryId}) => + MemoryAssetEntityData( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + MemoryAssetEntityData copyWithCompanion(MemoryAssetEntityCompanion data) { + return MemoryAssetEntityData( + assetId: data.assetId.present ? data.assetId.value : this.assetId, + memoryId: data.memoryId.present ? data.memoryId.value : this.memoryId, + ); + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityData(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(assetId, memoryId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MemoryAssetEntityData && + other.assetId == this.assetId && + other.memoryId == this.memoryId); +} + +class MemoryAssetEntityCompanion + extends UpdateCompanion { + final Value assetId; + final Value memoryId; + const MemoryAssetEntityCompanion({ + this.assetId = const Value.absent(), + this.memoryId = const Value.absent(), + }); + MemoryAssetEntityCompanion.insert({ + required String assetId, + required String memoryId, + }) : assetId = Value(assetId), + memoryId = Value(memoryId); + static Insertable custom({ + Expression? assetId, + Expression? memoryId, + }) { + return RawValuesInsertable({ + if (assetId != null) 'asset_id': assetId, + if (memoryId != null) 'memory_id': memoryId, + }); + } + + MemoryAssetEntityCompanion copyWith( + {Value? assetId, Value? memoryId}) { + return MemoryAssetEntityCompanion( + assetId: assetId ?? this.assetId, + memoryId: memoryId ?? this.memoryId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (assetId.present) { + map['asset_id'] = Variable(assetId.value); + } + if (memoryId.present) { + map['memory_id'] = Variable(memoryId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MemoryAssetEntityCompanion(') + ..write('assetId: $assetId, ') + ..write('memoryId: $memoryId') + ..write(')')) + .toString(); + } +} + +class PersonEntity extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PersonEntity(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const CustomExpression('CURRENT_TIMESTAMP')); + late final GeneratedColumn ownerId = GeneratedColumn( + 'owner_id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES user_entity (id) ON DELETE CASCADE')); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn faceAssetId = GeneratedColumn( + 'face_asset_id', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn thumbnailPath = GeneratedColumn( + 'thumbnail_path', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn isFavorite = GeneratedColumn( + 'is_favorite', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("is_favorite" IN (0, 1))')); + late final GeneratedColumn isHidden = GeneratedColumn( + 'is_hidden', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("is_hidden" IN (0, 1))')); + late final GeneratedColumn color = GeneratedColumn( + 'color', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false); + late final GeneratedColumn birthDate = GeneratedColumn( + 'birth_date', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + @override + List get $columns => [ + id, + createdAt, + updatedAt, + ownerId, + name, + faceAssetId, + thumbnailPath, + isFavorite, + isHidden, + color, + birthDate + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'person_entity'; + @override + Set get $primaryKey => {id}; + @override + PersonEntityData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PersonEntityData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + ownerId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}owner_id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + faceAssetId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}face_asset_id']), + thumbnailPath: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}thumbnail_path'])!, + isFavorite: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_favorite'])!, + isHidden: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}is_hidden'])!, + color: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}color']), + birthDate: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}birth_date']), + ); + } + + @override + PersonEntity createAlias(String alias) { + return PersonEntity(attachedDatabase, alias); + } + + @override + bool get withoutRowId => true; + @override + bool get isStrict => true; +} + +class PersonEntityData extends DataClass + implements Insertable { + final String id; + final DateTime createdAt; + final DateTime updatedAt; + final String ownerId; + final String name; + final String? faceAssetId; + final String thumbnailPath; + final bool isFavorite; + final bool isHidden; + final String? color; + final DateTime? birthDate; + const PersonEntityData( + {required this.id, + required this.createdAt, + required this.updatedAt, + required this.ownerId, + required this.name, + this.faceAssetId, + required this.thumbnailPath, + required this.isFavorite, + required this.isHidden, + this.color, + this.birthDate}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + map['owner_id'] = Variable(ownerId); + map['name'] = Variable(name); + if (!nullToAbsent || faceAssetId != null) { + map['face_asset_id'] = Variable(faceAssetId); + } + map['thumbnail_path'] = Variable(thumbnailPath); + map['is_favorite'] = Variable(isFavorite); + map['is_hidden'] = Variable(isHidden); + if (!nullToAbsent || color != null) { + map['color'] = Variable(color); + } + if (!nullToAbsent || birthDate != null) { + map['birth_date'] = Variable(birthDate); + } + return map; + } + + factory PersonEntityData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PersonEntityData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ownerId: serializer.fromJson(json['ownerId']), + name: serializer.fromJson(json['name']), + faceAssetId: serializer.fromJson(json['faceAssetId']), + thumbnailPath: serializer.fromJson(json['thumbnailPath']), + isFavorite: serializer.fromJson(json['isFavorite']), + isHidden: serializer.fromJson(json['isHidden']), + color: serializer.fromJson(json['color']), + birthDate: serializer.fromJson(json['birthDate']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'ownerId': serializer.toJson(ownerId), + 'name': serializer.toJson(name), + 'faceAssetId': serializer.toJson(faceAssetId), + 'thumbnailPath': serializer.toJson(thumbnailPath), + 'isFavorite': serializer.toJson(isFavorite), + 'isHidden': serializer.toJson(isHidden), + 'color': serializer.toJson(color), + 'birthDate': serializer.toJson(birthDate), + }; + } + + PersonEntityData copyWith( + {String? id, + DateTime? createdAt, + DateTime? updatedAt, + String? ownerId, + String? name, + Value faceAssetId = const Value.absent(), + String? thumbnailPath, + bool? isFavorite, + bool? isHidden, + Value color = const Value.absent(), + Value birthDate = const Value.absent()}) => + PersonEntityData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId.present ? faceAssetId.value : this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color.present ? color.value : this.color, + birthDate: birthDate.present ? birthDate.value : this.birthDate, + ); + PersonEntityData copyWithCompanion(PersonEntityCompanion data) { + return PersonEntityData( + id: data.id.present ? data.id.value : this.id, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ownerId: data.ownerId.present ? data.ownerId.value : this.ownerId, + name: data.name.present ? data.name.value : this.name, + faceAssetId: + data.faceAssetId.present ? data.faceAssetId.value : this.faceAssetId, + thumbnailPath: data.thumbnailPath.present + ? data.thumbnailPath.value + : this.thumbnailPath, + isFavorite: + data.isFavorite.present ? data.isFavorite.value : this.isFavorite, + isHidden: data.isHidden.present ? data.isHidden.value : this.isHidden, + color: data.color.present ? data.color.value : this.color, + birthDate: data.birthDate.present ? data.birthDate.value : this.birthDate, + ); + } + + @override + String toString() { + return (StringBuffer('PersonEntityData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, updatedAt, ownerId, name, + faceAssetId, thumbnailPath, isFavorite, isHidden, color, birthDate); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PersonEntityData && + other.id == this.id && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.ownerId == this.ownerId && + other.name == this.name && + other.faceAssetId == this.faceAssetId && + other.thumbnailPath == this.thumbnailPath && + other.isFavorite == this.isFavorite && + other.isHidden == this.isHidden && + other.color == this.color && + other.birthDate == this.birthDate); +} + +class PersonEntityCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value updatedAt; + final Value ownerId; + final Value name; + final Value faceAssetId; + final Value thumbnailPath; + final Value isFavorite; + final Value isHidden; + final Value color; + final Value birthDate; + const PersonEntityCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.ownerId = const Value.absent(), + this.name = const Value.absent(), + this.faceAssetId = const Value.absent(), + this.thumbnailPath = const Value.absent(), + this.isFavorite = const Value.absent(), + this.isHidden = const Value.absent(), + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }); + PersonEntityCompanion.insert({ + required String id, + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + required String ownerId, + required String name, + this.faceAssetId = const Value.absent(), + required String thumbnailPath, + required bool isFavorite, + required bool isHidden, + this.color = const Value.absent(), + this.birthDate = const Value.absent(), + }) : id = Value(id), + ownerId = Value(ownerId), + name = Value(name), + thumbnailPath = Value(thumbnailPath), + isFavorite = Value(isFavorite), + isHidden = Value(isHidden); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? updatedAt, + Expression? ownerId, + Expression? name, + Expression? faceAssetId, + Expression? thumbnailPath, + Expression? isFavorite, + Expression? isHidden, + Expression? color, + Expression? birthDate, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (ownerId != null) 'owner_id': ownerId, + if (name != null) 'name': name, + if (faceAssetId != null) 'face_asset_id': faceAssetId, + if (thumbnailPath != null) 'thumbnail_path': thumbnailPath, + if (isFavorite != null) 'is_favorite': isFavorite, + if (isHidden != null) 'is_hidden': isHidden, + if (color != null) 'color': color, + if (birthDate != null) 'birth_date': birthDate, + }); + } + + PersonEntityCompanion copyWith( + {Value? id, + Value? createdAt, + Value? updatedAt, + Value? ownerId, + Value? name, + Value? faceAssetId, + Value? thumbnailPath, + Value? isFavorite, + Value? isHidden, + Value? color, + Value? birthDate}) { + return PersonEntityCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ownerId: ownerId ?? this.ownerId, + name: name ?? this.name, + faceAssetId: faceAssetId ?? this.faceAssetId, + thumbnailPath: thumbnailPath ?? this.thumbnailPath, + isFavorite: isFavorite ?? this.isFavorite, + isHidden: isHidden ?? this.isHidden, + color: color ?? this.color, + birthDate: birthDate ?? this.birthDate, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (ownerId.present) { + map['owner_id'] = Variable(ownerId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (faceAssetId.present) { + map['face_asset_id'] = Variable(faceAssetId.value); + } + if (thumbnailPath.present) { + map['thumbnail_path'] = Variable(thumbnailPath.value); + } + if (isFavorite.present) { + map['is_favorite'] = Variable(isFavorite.value); + } + if (isHidden.present) { + map['is_hidden'] = Variable(isHidden.value); + } + if (color.present) { + map['color'] = Variable(color.value); + } + if (birthDate.present) { + map['birth_date'] = Variable(birthDate.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PersonEntityCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('ownerId: $ownerId, ') + ..write('name: $name, ') + ..write('faceAssetId: $faceAssetId, ') + ..write('thumbnailPath: $thumbnailPath, ') + ..write('isFavorite: $isFavorite, ') + ..write('isHidden: $isHidden, ') + ..write('color: $color, ') + ..write('birthDate: $birthDate') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + late final UserEntity userEntity = UserEntity(this); + late final RemoteAssetEntity remoteAssetEntity = RemoteAssetEntity(this); + late final LocalAssetEntity localAssetEntity = LocalAssetEntity(this); + late final StackEntity stackEntity = StackEntity(this); + late final Index idxLocalAssetChecksum = Index('idx_local_asset_checksum', + 'CREATE INDEX idx_local_asset_checksum ON local_asset_entity (checksum)'); + late final Index uQRemoteAssetOwnerChecksum = Index( + 'UQ_remote_asset_owner_checksum', + 'CREATE UNIQUE INDEX UQ_remote_asset_owner_checksum ON remote_asset_entity (checksum, owner_id)'); + late final Index idxRemoteAssetChecksum = Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); + late final UserMetadataEntity userMetadataEntity = UserMetadataEntity(this); + late final PartnerEntity partnerEntity = PartnerEntity(this); + late final LocalAlbumEntity localAlbumEntity = LocalAlbumEntity(this); + late final LocalAlbumAssetEntity localAlbumAssetEntity = + LocalAlbumAssetEntity(this); + late final RemoteExifEntity remoteExifEntity = RemoteExifEntity(this); + late final RemoteAlbumEntity remoteAlbumEntity = RemoteAlbumEntity(this); + late final RemoteAlbumAssetEntity remoteAlbumAssetEntity = + RemoteAlbumAssetEntity(this); + late final RemoteAlbumUserEntity remoteAlbumUserEntity = + RemoteAlbumUserEntity(this); + late final MemoryEntity memoryEntity = MemoryEntity(this); + late final MemoryAssetEntity memoryAssetEntity = MemoryAssetEntity(this); + late final PersonEntity personEntity = PersonEntity(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + userEntity, + remoteAssetEntity, + localAssetEntity, + stackEntity, + idxLocalAssetChecksum, + uQRemoteAssetOwnerChecksum, + idxRemoteAssetChecksum, + userMetadataEntity, + partnerEntity, + localAlbumEntity, + localAlbumAssetEntity, + remoteExifEntity, + remoteAlbumEntity, + remoteAlbumAssetEntity, + remoteAlbumUserEntity, + memoryEntity, + memoryAssetEntity, + personEntity + ]; + @override + int get schemaVersion => 3; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} From 4d27f187eaf776a6a3e7ebb08d8309793a09fb4b Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Jul 2025 15:30:51 -0500 Subject: [PATCH 39/45] feat: new upload (cont) (#20029) * new upload button * wip * pr feedback * fix: updateAll override album selection value * feat: status box * feat: handle upload resume * re-enable websocket event * fix: update state condition and upload status * Better backup detail page --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- i18n/en.json | 8 + .../app/alextran/immich/BackupWorker.kt | 1 + mobile/lib/constants/constants.dart | 3 + mobile/lib/domain/models/store.model.dart | 3 +- .../domain/services/local_album.service.dart | 4 +- mobile/lib/domain/utils/background_sync.dart | 16 +- .../extensions/build_context_extensions.dart | 4 + .../repositories/backup.repository.dart | 9 +- .../repositories/local_album.repository.dart | 21 +- mobile/lib/main.dart | 4 +- .../lib/pages/backup/drift_backup.page.dart | 125 ++- .../drift_backup_album_selection.page.dart | 720 ++++++++++++------ .../backup/drift_upload_detail.page.dart | 428 +++++++++++ mobile/lib/pages/common/tab_shell.page.dart | 17 +- .../backup/backup_toggle_button.widget.dart | 242 ++++++ .../providers/app_life_cycle.provider.dart | 24 +- .../backup/backup_album.provider.dart | 4 +- .../backup/drift_backup.provider.dart | 231 +++++- mobile/lib/providers/websocket.provider.dart | 12 +- mobile/lib/routing/router.dart | 5 + mobile/lib/routing/router.gr.dart | 16 + mobile/lib/services/app_settings.service.dart | 1 + mobile/lib/services/drift_backup.service.dart | 15 +- mobile/lib/services/upload.service.dart | 1 + .../widgets/common/immich_sliver_app_bar.dart | 6 + .../settings/beta_timeline_list_tile.dart | 51 +- 26 files changed, 1558 insertions(+), 413 deletions(-) create mode 100644 mobile/lib/pages/backup/drift_upload_detail.page.dart create mode 100644 mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart diff --git a/i18n/en.json b/i18n/en.json index 77dc2e235c..8961943678 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -406,6 +406,7 @@ "album_options": "Album options", "album_remove_user": "Remove user?", "album_remove_user_confirmation": "Are you sure you want to remove {user}?", + "album_search_not_found": "No albums found matching your search", "album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.", "album_updated": "Album updated", "album_updated_setting_description": "Receive an email notification when a shared album has new assets", @@ -425,6 +426,7 @@ "albums_default_sort_order": "Default album sort order", "albums_default_sort_order_description": "Initial asset sort order when creating new albums.", "albums_feature_description": "Collections of assets that can be shared with other users.", + "albums_on_device_count": "Albums on device ({count})", "all": "All", "all_albums": "All albums", "all_people": "All people", @@ -605,6 +607,7 @@ "cancel": "Cancel", "cancel_search": "Cancel search", "canceled": "Canceled", + "canceling": "Canceling", "cannot_merge_people": "Cannot merge people", "cannot_undo_this_action": "You cannot undo this action!", "cannot_update_the_description": "Cannot update the description", @@ -765,6 +768,7 @@ "description": "Description", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", + "deselect_all": "Deselect All", "details": "Details", "direction": "Direction", "disabled": "Disabled", @@ -839,6 +843,7 @@ "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", "enable": "Enable", + "enable_backup": "Enable Backup", "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication", "enabled": "Enabled", "end_date": "End date", @@ -1485,6 +1490,7 @@ "purchase_server_description_2": "Supporter status", "purchase_server_title": "Server", "purchase_settings_server_activated": "The server product key is managed by the admin", + "queue_status": "Queuing {count}/{total}", "rating": "Star rating", "rating_clear": "Clear rating", "rating_count": "{count, plural, one {# star} other {# stars}}", @@ -1915,6 +1921,7 @@ "updated_password": "Updated password", "upload": "Upload", "upload_concurrency": "Upload concurrency", + "upload_details": "Upload Details", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_title": "Upload Asset", "upload_errors": "Upload completed with {count, plural, one {# error} other {# errors}}, refresh the page to see new upload assets.", @@ -1965,6 +1972,7 @@ "view_album": "View Album", "view_all": "View All", "view_all_users": "View all users", + "view_details": "View Details", "view_in_timeline": "View in timeline", "view_link": "View link", "view_links": "View links", diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt index 0fb75b002c..9c90528dc9 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/BackupWorker.kt @@ -83,6 +83,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct flutterLoader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) { runDart() + } return resolvableFuture diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 37a3eec073..206fbbb28f 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -38,3 +38,6 @@ const List<(String, String)> kWidgetNames = [ ('com.immich.widget.random', 'app.alextran.immich.widget.RandomReceiver'), ('com.immich.widget.memory', 'app.alextran.immich.widget.MemoryReceiver'), ]; + +const double kUploadStatusFailed = -1.0; +const double kUploadStatusCanceled = -2.0; diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index 61c5807ba8..305b3f3387 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -70,7 +70,8 @@ enum StoreKey { // Experimental stuff photoManagerCustomFilter._(1000), betaPromptShown._(1001), - betaTimeline._(1002); + betaTimeline._(1002), + enableBackup._(1003); const StoreKey._(this.id); final int id; diff --git a/mobile/lib/domain/services/local_album.service.dart b/mobile/lib/domain/services/local_album.service.dart index 7ec9231196..79cc58f3e0 100644 --- a/mobile/lib/domain/services/local_album.service.dart +++ b/mobile/lib/domain/services/local_album.service.dart @@ -7,8 +7,8 @@ class LocalAlbumService { const LocalAlbumService(this._repository); - Future> getAll() { - return _repository.getAll(); + Future> getAll({Set sortBy = const {}}) { + return _repository.getAll(sortBy: sortBy); } Future getThumbnail(String albumId) { diff --git a/mobile/lib/domain/utils/background_sync.dart b/mobile/lib/domain/utils/background_sync.dart index 7ab2989354..4a44c4d8f2 100644 --- a/mobile/lib/domain/utils/background_sync.dart +++ b/mobile/lib/domain/utils/background_sync.dart @@ -101,14 +101,18 @@ class BackgroundSyncManager { if (_syncWebsocketTask != null) { return _syncWebsocketTask!.future; } - - _syncWebsocketTask = runInIsolateGentle( - computation: (ref) => ref - .read(syncStreamServiceProvider) - .handleWsAssetUploadReadyV1Batch(batchData), - ); + _syncWebsocketTask = _handleWsAssetUploadReadyV1Batch(batchData); return _syncWebsocketTask!.whenComplete(() { _syncWebsocketTask = null; }); } } + +Cancelable _handleWsAssetUploadReadyV1Batch( + List batchData, +) => + runInIsolateGentle( + computation: (ref) => ref + .read(syncStreamServiceProvider) + .handleWsAssetUploadReadyV1Batch(batchData), + ); diff --git a/mobile/lib/extensions/build_context_extensions.dart b/mobile/lib/extensions/build_context_extensions.dart index 69a9c3b347..7bb194cdae 100644 --- a/mobile/lib/extensions/build_context_extensions.dart +++ b/mobile/lib/extensions/build_context_extensions.dart @@ -33,6 +33,10 @@ extension ContextHelper on BuildContext { // Returns the current Primary color of the Theme Color get primaryColor => themeData.colorScheme.primary; + Color get logoYellow => const Color.fromARGB(255, 255, 184, 0); + Color get logoRed => const Color.fromARGB(255, 230, 65, 30); + Color get logoPink => const Color.fromARGB(255, 222, 127, 179); + Color get logoGreen => const Color.fromARGB(255, 49, 164, 82); // Returns the Scaffold background color of the Theme Color get scaffoldBackgroundColor => colorScheme.surface; diff --git a/mobile/lib/infrastructure/repositories/backup.repository.dart b/mobile/lib/infrastructure/repositories/backup.repository.dart index 4ce3b07e8b..99df206db5 100644 --- a/mobile/lib/infrastructure/repositories/backup.repository.dart +++ b/mobile/lib/infrastructure/repositories/backup.repository.dart @@ -52,9 +52,7 @@ class DriftBackupRepository extends DriftDatabaseRepository { Future getRemainderCount() async { final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) - ..addColumns( - [_db.localAlbumAssetEntity.assetId], - ) + ..addColumns([_db.localAlbumAssetEntity.assetId]) ..join([ innerJoin( _db.localAlbumEntity, @@ -147,6 +145,11 @@ class DriftBackupRepository extends DriftDatabaseRepository { ), ) & lae.id.isNotInQuery(_getExcludedSubquery()), + ) + ..orderBy( + [ + (localAsset) => OrderingTerm.desc(localAsset.createdAt), + ], ); return query.map((localAsset) => localAsset.toDto()).get(); diff --git a/mobile/lib/infrastructure/repositories/local_album.repository.dart b/mobile/lib/infrastructure/repositories/local_album.repository.dart index ba9dfd979d..5f192a20cf 100644 --- a/mobile/lib/infrastructure/repositories/local_album.repository.dart +++ b/mobile/lib/infrastructure/repositories/local_album.repository.dart @@ -8,7 +8,13 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/utils/database.utils.dart'; import 'package:platform/platform.dart'; -enum SortLocalAlbumsBy { id, backupSelection, isIosSharedAlbum } +enum SortLocalAlbumsBy { + id, + backupSelection, + isIosSharedAlbum, + name, + assetCount +} class DriftLocalAlbumRepository extends DriftDatabaseRepository { final Drift _db; @@ -41,6 +47,9 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { OrderingTerm.asc(_db.localAlbumEntity.backupSelection), SortLocalAlbumsBy.isIosSharedAlbum => OrderingTerm.asc(_db.localAlbumEntity.isIosSharedAlbum), + SortLocalAlbumsBy.name => + OrderingTerm.asc(_db.localAlbumEntity.name), + SortLocalAlbumsBy.assetCount => OrderingTerm.desc(assetCount), }, ); } @@ -151,7 +160,15 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository { batch.insert( _db.localAlbumEntity, companion, - onConflict: DoUpdate((_) => companion), + onConflict: DoUpdate( + (old) => LocalAlbumEntityCompanion( + id: companion.id, + name: companion.name, + updatedAt: companion.updatedAt, + isIosSharedAlbum: companion.isIosSharedAlbum, + marker_: companion.marker_, + ), + ), ); } }); diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index acadf4c887..f036fd9bc3 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -96,8 +96,8 @@ Future initApp() async { // Initialize the file downloader await FileDownloader().configure( - // maxConcurrent: 5, maxConcurrentByHost: 2, maxConcurrentByGroup: 3 - globalConfig: (Config.holdingQueue, (5, 2, 3)), + // maxConcurrent: 6, maxConcurrentByHost(server):6, maxConcurrentByGroup: 3 + globalConfig: (Config.holdingQueue, (6, 6, 3)), ); await FileDownloader().trackTasksInGroup( diff --git a/mobile/lib/pages/backup/drift_backup.page.dart b/mobile/lib/pages/backup/drift_backup.page.dart index 34649ca42c..1b9ec8ad07 100644 --- a/mobile/lib/pages/backup/drift_backup.page.dart +++ b/mobile/lib/pages/backup/drift_backup.page.dart @@ -1,83 +1,61 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.widget.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; -import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/backup/backup_info_card.dart'; @RoutePage() -class DriftBackupPage extends HookConsumerWidget { +class DriftBackupPage extends ConsumerStatefulWidget { const DriftBackupPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - useEffect( - () { - ref.read(driftBackupProvider.notifier).getBackupStatus(); - return null; - }, - [], - ); + ConsumerState createState() => _DriftBackupPageState(); +} - Widget buildControlButtons() { - return Padding( - padding: const EdgeInsets.only( - top: 24, - ), - child: Column( - children: [ - ElevatedButton( - onPressed: () => ref.read(driftBackupProvider.notifier).backup(), - child: const Text( - "backup_controller_page_start_backup", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - OutlinedButton( - onPressed: () => ref.read(driftBackupProvider.notifier).cancel(), - child: const Text( - "cancel", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - OutlinedButton( - onPressed: () => - ref.read(driftBackupProvider.notifier).getDataInfo(), - child: const Text( - "Get database info", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ).tr(), - ), - ], - ), - ); - } +class _DriftBackupPageState extends ConsumerState { + @override + void initState() { + super.initState(); + ref.read(driftBackupProvider.notifier).getBackupStatus(); + } + + Future startBackup() async { + await ref.read(driftBackupProvider.notifier).getBackupStatus(); + await ref.read(driftBackupProvider.notifier).backup(); + } + + Future stopBackup() async { + await ref.read(driftBackupProvider.notifier).cancel(); + } + + @override + Widget build(BuildContext context) { + final selectedAlbum = ref + .watch(backupAlbumProvider) + .where( + (album) => album.backupSelection == BackupSelection.selected, + ) + .toList(); + final uploadItems = ref.watch( + driftBackupProvider.select((state) => state.uploadItems), + ); return Scaffold( appBar: AppBar( elevation: 0, - title: const Text( - "Backup (Experimental)", + title: Text( + "backup_controller_page_backup".t(), ), leading: IconButton( onPressed: () { - ref.watch(websocketProvider.notifier).listenUploadEvent(); context.maybePop(true); }, splashRadius: 24, @@ -85,18 +63,6 @@ class DriftBackupPage extends HookConsumerWidget { Icons.arrow_back_ios_rounded, ), ), - actions: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: IconButton( - onPressed: () => context.pushRoute(const BackupOptionsRoute()), - splashRadius: 24, - icon: const Icon( - Icons.settings_outlined, - ), - ), - ), - ], ), body: Stack( children: [ @@ -110,11 +76,24 @@ class DriftBackupPage extends HookConsumerWidget { children: [ const SizedBox(height: 8), const _BackupAlbumSelectionCard(), - const _TotalCard(), - const _BackupCard(), - const _RemainderCard(), - const Divider(), - buildControlButtons(), + if (selectedAlbum.isNotEmpty) ...[ + const _TotalCard(), + const _BackupCard(), + const _RemainderCard(), + const Divider(), + BackupToggleButton( + onStart: () async => await startBackup(), + onStop: () async => await stopBackup(), + ), + if (uploadItems.isNotEmpty) + TextButton.icon( + icon: const Icon(Icons.info_outline_rounded), + onPressed: () => context.pushRoute( + const DriftUploadDetailRoute(), + ), + label: Text("view_details".t(context: context)), + ), + ], ], ), ), diff --git a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart index 2e7a2d4c2d..fd39f0a579 100644 --- a/mobile/lib/pages/backup/drift_backup_album_selection.page.dart +++ b/mobile/lib/pages/backup/drift_backup_album_selection.page.dart @@ -1,25 +1,67 @@ +import 'dart:io'; + import 'package:auto_route/auto_route.dart'; -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; -import 'package:immich_mobile/providers/backup/backup.provider.dart'; import 'package:immich_mobile/providers/backup/backup_album.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; -import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/widgets/backup/drift_album_info_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; @RoutePage() -class DriftBackupAlbumSelectionPage extends HookConsumerWidget { +class DriftBackupAlbumSelectionPage extends ConsumerStatefulWidget { const DriftBackupAlbumSelectionPage({super.key}); + @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _DriftBackupAlbumSelectionPageState(); +} + +class _DriftBackupAlbumSelectionPageState + extends ConsumerState { + String _searchQuery = ''; + bool _isSearchMode = false; + late ValueNotifier _enableSyncUploadAlbum; + late TextEditingController _searchController; + late FocusNode _searchFocusNode; + + @override + void initState() { + super.initState(); + _enableSyncUploadAlbum = ValueNotifier(false); + _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); + + _enableSyncUploadAlbum.value = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.syncAlbums); + ref.read(backupAlbumProvider.notifier).getAll(); + } + + @override + void dispose() { + _enableSyncUploadAlbum.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { final albums = ref.watch(backupAlbumProvider); + final albumCount = albums.length; + // Filter albums based on search query + final filteredAlbums = albums.where((album) { + if (_searchQuery.isEmpty) return true; + return album.name.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); final selectedBackupAlbums = albums .where((album) => album.backupSelection == BackupSelection.selected) @@ -27,133 +69,6 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget { final excludedBackupAlbums = albums .where((album) => album.backupSelection == BackupSelection.excluded) .toList(); - final enableSyncUploadAlbum = - useAppSettingsState(AppSettingsEnum.syncAlbums); - final isDarkTheme = context.isDarkTheme; - - useEffect( - () { - ref.watch(backupProvider.notifier).getBackupInfo(); - ref.watch(backupAlbumProvider.notifier).getAll(); - return null; - }, - [], - ); - - buildAlbumSelectionList() { - if (albums.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(vertical: 12.0), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - ((context, index) { - return DriftAlbumInfoListTile( - album: albums[index], - ); - }), - childCount: albums.length, - ), - ), - ); - } - - buildAlbumSelectionGrid() { - if (albums.isEmpty) { - return const SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.all(12.0), - sliver: SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 300, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - ), - itemCount: albums.length, - itemBuilder: ((context, index) { - return DriftAlbumInfoListTile( - album: albums[index], - ); - }), - ), - ); - } - - buildSelectedAlbumNameChip() { - return selectedBackupAlbums.map((album) { - void removeSelection() { - ref.read(backupAlbumProvider.notifier).deselectAlbum(album); - } - - return Padding( - padding: const EdgeInsets.only(right: 8.0), - child: GestureDetector( - onTap: removeSelection, - child: Chip( - label: Text( - album.name, - style: TextStyle( - fontSize: 12, - color: isDarkTheme ? Colors.black : Colors.white, - fontWeight: FontWeight.bold, - ), - ), - backgroundColor: context.primaryColor, - deleteIconColor: isDarkTheme ? Colors.black : Colors.white, - deleteIcon: const Icon( - Icons.cancel_rounded, - size: 15, - ), - onDeleted: removeSelection, - ), - ), - ); - }).toSet(); - } - - buildExcludedAlbumNameChip() { - return excludedBackupAlbums.map((album) { - void removeSelection() { - ref.read(backupAlbumProvider.notifier).deselectAlbum(album); - } - - return GestureDetector( - onTap: removeSelection, - child: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Chip( - label: Text( - album.name, - style: TextStyle( - fontSize: 12, - color: context.scaffoldBackgroundColor, - fontWeight: FontWeight.bold, - ), - ), - backgroundColor: Colors.red[300], - deleteIconColor: context.scaffoldBackgroundColor, - deleteIcon: const Icon( - Icons.cancel_rounded, - size: 15, - ), - onDeleted: removeSelection, - ), - ), - ); - }).toSet(); - } handleSyncAlbumToggle(bool isEnable) async { if (isEnable) { @@ -170,138 +85,439 @@ class DriftBackupAlbumSelectionPage extends HookConsumerWidget { onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded), ), - title: const Text( - "backup_album_selection_page_select_albums", - ).tr(), + title: _isSearchMode + ? SearchField( + hintText: 'search_albums'.t(context: context), + autofocus: true, + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) => + setState(() => _searchQuery = value.trim()), + ) + : const Text( + "backup_album_selection_page_select_albums", + ).t(context: context), + actions: [ + if (!_isSearchMode) + IconButton( + icon: const Icon(Icons.search), + onPressed: () => setState(() { + _isSearchMode = true; + _searchQuery = ''; + }), + ) + else + IconButton( + icon: const Icon(Icons.close), + onPressed: () => setState(() { + _isSearchMode = false; + _searchQuery = ''; + _searchController.clear(); + }), + ), + ], elevation: 0, ), - body: SafeArea( - child: CustomScrollView( - physics: const ClampingScrollPhysics(), - slivers: [ - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - horizontal: 16.0, - ), - child: Text( - "backup_album_selection_page_selection_info", - style: context.textTheme.titleSmall, - ).tr(), + body: CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16.0, ), - // Selected Album Chips + child: Text( + "backup_album_selection_page_selection_info", + style: context.textTheme.titleSmall, + ).t(context: context), + ), + // Selected Album Chips - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Wrap( - children: [ - ...buildSelectedAlbumNameChip(), - ...buildExcludedAlbumNameChip(), - ], - ), - ), - - SettingsSwitchListTile( - valueNotifier: enableSyncUploadAlbum, - title: "sync_albums".tr(), - subtitle: "sync_upload_album_setting_subtitle".tr(), - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - titleStyle: context.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - subtitleStyle: context.textTheme.labelLarge?.copyWith( - color: context.colorScheme.primary, - ), - onChanged: handleSyncAlbumToggle, - ), - - ListTile( - title: Text( - "backup_album_selection_page_albums_device".tr( - namedArgs: { - 'count': ref - .watch(backupProvider) - .availableAlbums - .length - .toString(), - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap( + children: [ + _SelectedAlbumNameChips( + selectedBackupAlbums: selectedBackupAlbums, ), - style: context.textTheme.titleSmall, + _ExcludedAlbumNameChips( + excludedBackupAlbums: excludedBackupAlbums, + ), + ], + ), + ), + + SettingsSwitchListTile( + valueNotifier: _enableSyncUploadAlbum, + title: "sync_albums".t(context: context), + subtitle: + "sync_upload_album_setting_subtitle".t(context: context), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + titleStyle: context.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.primary, + ), + onChanged: handleSyncAlbumToggle, + ), + + ListTile( + title: Text( + "albums_on_device_count".t( + context: context, + args: {'count': albumCount.toString()}, ), - subtitle: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - "backup_album_selection_page_albums_tap", - style: context.textTheme.labelLarge?.copyWith( - color: context.primaryColor, - ), - ).tr(), - ), - trailing: IconButton( - splashRadius: 16, - icon: Icon( - Icons.info, - size: 20, + style: context.textTheme.titleSmall, + ), + subtitle: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "backup_album_selection_page_albums_tap", + style: context.textTheme.labelLarge?.copyWith( color: context.primaryColor, ), - onPressed: () { - // show the dialog - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - shape: const RoundedRectangleBorder( - borderRadius: - BorderRadius.all(Radius.circular(10)), - ), - elevation: 5, - title: Text( - 'backup_album_selection_page_selection_info', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: context.primaryColor, - ), - ).tr(), - content: SingleChildScrollView( - child: ListBody( - children: [ - const Text( - 'backup_album_selection_page_assets_scatter', - style: TextStyle( - fontSize: 14, - ), - ).tr(), - ], - ), - ), - ); - }, - ); - }, - ), + ).t(context: context), ), + trailing: IconButton( + splashRadius: 16, + icon: Icon( + Icons.info, + size: 20, + color: context.primaryColor, + ), + onPressed: () { + // show the dialog + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(10)), + ), + elevation: 5, + title: Text( + 'backup_album_selection_page_selection_info', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: context.primaryColor, + ), + ).t(context: context), + content: SingleChildScrollView( + child: ListBody( + children: [ + const Text( + 'backup_album_selection_page_assets_scatter', + style: TextStyle( + fontSize: 14, + ), + ).t(context: context), + ], + ), + ), + ); + }, + ); + }, + ), + ), - // buildSearchBar(), - ], - ), + if (Platform.isAndroid) + _SelectAllButton( + filteredAlbums: filteredAlbums, + selectedBackupAlbums: selectedBackupAlbums, + ), + ], ), - SliverLayoutBuilder( - builder: (context, constraints) { - if (constraints.crossAxisExtent > 600) { - return buildAlbumSelectionGrid(); - } else { - return buildAlbumSelectionList(); - } - }, - ), - ], + ), + SliverLayoutBuilder( + builder: (context, constraints) { + if (constraints.crossAxisExtent > 600) { + return _AlbumSelectionGrid( + filteredAlbums: filteredAlbums, + searchQuery: _searchQuery, + ); + } else { + return _AlbumSelectionList( + filteredAlbums: filteredAlbums, + searchQuery: _searchQuery, + ); + } + }, + ), + ], + ), + ); + } +} + +class _AlbumSelectionList extends StatelessWidget { + final List filteredAlbums; + final String searchQuery; + + const _AlbumSelectionList({ + required this.filteredAlbums, + required this.searchQuery, + }); + + @override + Widget build(BuildContext context) { + if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) { + return SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text('album_search_not_found'.t(context: context)), + ), + ), + ); + } + + if (filteredAlbums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + ((context, index) { + return DriftAlbumInfoListTile( + album: filteredAlbums[index], + ); + }), + childCount: filteredAlbums.length, ), ), ); } } + +class _AlbumSelectionGrid extends StatelessWidget { + final List filteredAlbums; + final String searchQuery; + + const _AlbumSelectionGrid({ + required this.filteredAlbums, + required this.searchQuery, + }); + + @override + Widget build(BuildContext context) { + if (filteredAlbums.isEmpty && searchQuery.isNotEmpty) { + return SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text('album_search_not_found'.t(context: context)), + ), + ), + ); + } + + if (filteredAlbums.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + return SliverPadding( + padding: const EdgeInsets.all(12.0), + sliver: SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 300, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: filteredAlbums.length, + itemBuilder: ((context, index) { + return DriftAlbumInfoListTile( + album: filteredAlbums[index], + ); + }), + ), + ); + } +} + +class _SelectedAlbumNameChips extends ConsumerWidget { + final List selectedBackupAlbums; + + const _SelectedAlbumNameChips({ + required this.selectedBackupAlbums, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Wrap( + children: selectedBackupAlbums.asMap().entries.map((entry) { + final album = entry.value; + + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: GestureDetector( + onTap: removeSelection, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: context.isDarkTheme ? Colors.black : Colors.white, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: context.primaryColor, + deleteIconColor: + context.isDarkTheme ? Colors.black : Colors.white, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _ExcludedAlbumNameChips extends ConsumerWidget { + final List excludedBackupAlbums; + + const _ExcludedAlbumNameChips({ + required this.excludedBackupAlbums, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Wrap( + children: excludedBackupAlbums.asMap().entries.map((entry) { + final album = entry.value; + + void removeSelection() { + ref.read(backupAlbumProvider.notifier).deselectAlbum(album); + } + + return GestureDetector( + onTap: removeSelection, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Chip( + label: Text( + album.name, + style: TextStyle( + fontSize: 12, + color: context.scaffoldBackgroundColor, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.red[300], + deleteIconColor: context.scaffoldBackgroundColor, + deleteIcon: const Icon( + Icons.cancel_rounded, + size: 15, + ), + onDeleted: removeSelection, + ), + ), + ), + ); + }).toList(), + ); + } +} + +class _SelectAllButton extends ConsumerWidget { + final List filteredAlbums; + final List selectedBackupAlbums; + + const _SelectAllButton({ + required this.filteredAlbums, + required this.selectedBackupAlbums, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canSelectAll = filteredAlbums + .where((album) => album.backupSelection != BackupSelection.selected) + .isNotEmpty; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: canSelectAll + ? () { + for (final album in filteredAlbums) { + if (album.backupSelection != BackupSelection.selected) { + ref + .read(backupAlbumProvider.notifier) + .selectAlbum(album); + } + } + } + : null, + icon: const Icon(Icons.select_all), + label: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Text( + "select_all".t(context: context), + ), + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12.0), + ), + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: OutlinedButton.icon( + onPressed: selectedBackupAlbums.isNotEmpty + ? () { + for (final album in filteredAlbums) { + if (album.backupSelection == BackupSelection.selected) { + ref + .read(backupAlbumProvider.notifier) + .deselectAlbum(album); + } + } + } + : null, + icon: const Icon(Icons.deselect), + label: Text('deselect_all'.t(context: context)), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12.0), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/pages/backup/drift_upload_detail.page.dart b/mobile/lib/pages/backup/drift_upload_detail.page.dart new file mode 100644 index 0000000000..66803265e6 --- /dev/null +++ b/mobile/lib/pages/backup/drift_upload_detail.page.dart @@ -0,0 +1,428 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; +import 'package:path/path.dart' as path; + +@RoutePage() +class DriftUploadDetailPage extends ConsumerWidget { + const DriftUploadDetailPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final uploadItems = ref.watch( + driftBackupProvider.select((state) => state.uploadItems), + ); + + return Scaffold( + appBar: AppBar( + title: Text("upload_details".t(context: context)), + backgroundColor: context.colorScheme.surface, + elevation: 0, + scrolledUnderElevation: 1, + ), + body: uploadItems.isEmpty + ? _buildEmptyState(context) + : _buildUploadList(uploadItems), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_upload_outlined, + size: 80, + color: context.colorScheme.onSurface.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + "no_uploads_in_progress".t(context: context), + style: context.textTheme.titleMedium?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + Widget _buildUploadList( + Map uploadItems, + ) { + return ListView.separated( + addAutomaticKeepAlives: true, + padding: const EdgeInsets.all(16), + itemCount: uploadItems.length, + separatorBuilder: (context, index) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final item = uploadItems.values.elementAt(index); + return _buildUploadCard(context, item); + }, + ); + } + + Widget _buildUploadCard( + BuildContext context, + DriftUploadStatus item, + ) { + final isCompleted = item.progress >= 1.0; + final double progressPercentage = (item.progress * 100).clamp(0, 100); + + return Card( + elevation: 0, + color: context.colorScheme.surfaceContainer, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + side: BorderSide( + color: context.colorScheme.outline.withValues(alpha: 0.1), + width: 1, + ), + ), + child: InkWell( + onTap: () => _showFileDetailDialog(context, item), + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path.basename(item.filename), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'Tap for more details', + style: context.textTheme.bodySmall?.copyWith( + color: context.colorScheme.onSurface + .withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + _buildProgressIndicator( + context, + item.progress, + progressPercentage, + isCompleted, + item.networkSpeedAsString, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildProgressIndicator( + BuildContext context, + double progress, + double percentage, + bool isCompleted, + String networkSpeedAsString, + ) { + return Column( + children: [ + Stack( + alignment: AlignmentDirectional.center, + children: [ + SizedBox( + width: 36, + height: 36, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: progress), + duration: const Duration(milliseconds: 300), + builder: (context, value, _) => CircularProgressIndicator( + backgroundColor: + context.colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 3, + value: value, + color: isCompleted + ? context.colorScheme.primary + : context.colorScheme.secondary, + ), + ), + ), + if (isCompleted) + Icon( + Icons.check_circle_rounded, + size: 28, + color: context.colorScheme.primary, + ) + else + Text( + percentage.toStringAsFixed(0), + style: context.textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + Text( + networkSpeedAsString, + style: context.textTheme.labelSmall?.copyWith( + color: context.colorScheme.onSurface.withValues(alpha: 0.6), + fontSize: 10, + ), + ), + ], + ); + } + + Future _showFileDetailDialog( + BuildContext context, + DriftUploadStatus item, + ) async { + showDialog( + context: context, + builder: (context) => FileDetailDialog(uploadStatus: item), + ); + } +} + +class FileDetailDialog extends ConsumerWidget { + final DriftUploadStatus uploadStatus; + + const FileDetailDialog({ + super.key, + required this.uploadStatus, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AlertDialog( + insetPadding: const EdgeInsets.all(20), + backgroundColor: context.colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all( + Radius.circular(16), + ), + side: BorderSide( + color: context.colorScheme.outline.withValues(alpha: 0.2), + width: 1, + ), + ), + title: Row( + children: [ + Icon( + Icons.info_outline, + color: context.primaryColor, + size: 24, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + "details".t(context: context), + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ), + ), + ], + ), + content: SizedBox( + width: double.maxFinite, + child: FutureBuilder( + future: _getAssetDetails(ref, uploadStatus.taskId), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ); + } + + final asset = snapshot.data; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Thumbnail at the top center + Center( + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Container( + width: 128, + height: 128, + decoration: BoxDecoration( + border: Border.all( + color: context.colorScheme.outline + .withValues(alpha: 0.2), + width: 1, + ), + borderRadius: + const BorderRadius.all(Radius.circular(12)), + ), + child: asset != null + ? Thumbnail( + asset: asset, + size: const Size(512, 512), + fit: BoxFit.cover, + ) + : null, + ), + ), + ), + const SizedBox(height: 24), + if (asset != null) ...[ + _buildInfoSection(context, [ + _buildInfoRow( + context, + "Filename", + path.basename(uploadStatus.filename), + ), + _buildInfoRow( + context, + "Local ID", + asset.id, + ), + _buildInfoRow( + context, + "File Size", + formatHumanReadableBytes(uploadStatus.fileSize, 2), + ), + if (asset.width != null) + _buildInfoRow(context, "Width", "${asset.width}px"), + if (asset.height != null) + _buildInfoRow( + context, + "Height", + "${asset.height}px", + ), + _buildInfoRow( + context, + "Created At", + asset.createdAt.toString(), + ), + _buildInfoRow( + context, + "Updated At", + asset.updatedAt.toString(), + ), + if (asset.checksum != null) + _buildInfoRow( + context, + "Checksum", + asset.checksum!, + ), + ]), + ], + ], + ), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + "close".t(), + style: TextStyle( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ), + ), + ], + ); + } + + Widget _buildInfoSection( + BuildContext context, + List children, + ) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainer, + borderRadius: const BorderRadius.all( + Radius.circular(12), + ), + border: Border.all( + color: context.colorScheme.outline.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...children, + ], + ), + ); + } + + Widget _buildInfoRow(BuildContext context, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + "$label:", + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w500, + color: context.colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + Expanded( + child: Text( + value, + style: context.textTheme.labelMedium?.copyWith(), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Future _getAssetDetails( + WidgetRef ref, + String localAssetId, + ) async { + try { + final repository = ref.read(localAssetRepository); + return await repository.getById(localAssetId); + } catch (e) { + return null; + } + } +} diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 671c1a6156..b0be136a15 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -3,7 +3,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; @@ -11,6 +13,7 @@ import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/utils/migration.dart'; @RoutePage() @@ -25,9 +28,19 @@ class _TabShellPageState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { + + WidgetsBinding.instance.addPostFrameCallback((_) async { ref.read(websocketProvider.notifier).connect(); - runNewSync(ref, full: true); + + final isEnableBackup = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + + await runNewSync(ref, full: true).then((_) async { + if (isEnableBackup) { + await ref.read(driftBackupProvider.notifier).handleBackupResume(); + } + }); }); } diff --git a/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart new file mode 100644 index 0000000000..c817b9b4b6 --- /dev/null +++ b/mobile/lib/presentation/widgets/backup/backup_toggle_button.widget.dart @@ -0,0 +1,242 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; + +class BackupToggleButton extends ConsumerStatefulWidget { + final VoidCallback onStart; + final VoidCallback onStop; + + const BackupToggleButton({ + super.key, + required this.onStart, + required this.onStop, + }); + + @override + ConsumerState createState() => BackupToggleButtonState(); +} + +class BackupToggleButtonState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _gradientAnimation; + bool _isEnabled = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(seconds: 8), + vsync: this, + ); + + _gradientAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); + + _isEnabled = ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _onToggle(bool value) async { + await ref + .read(appSettingsServiceProvider) + .setSetting(AppSettingsEnum.enableBackup, value); + + setState(() { + _isEnabled = value; + }); + + if (value) { + widget.onStart.call(); + } else { + widget.onStop.call(); + } + } + + @override + Widget build(BuildContext context) { + final enqueueCount = ref.watch( + driftBackupProvider.select((state) => state.enqueueCount), + ); + + final enqueueTotalCount = ref.watch( + driftBackupProvider.select((state) => state.enqueueTotalCount), + ); + + final isCanceling = ref.watch( + driftBackupProvider.select((state) => state.isCanceling), + ); + + final uploadTasks = ref.watch( + driftBackupProvider.select((state) => state.uploadItems), + ); + + final isUploading = uploadTasks.isNotEmpty; + + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final gradientColors = [ + Color.lerp( + context.primaryColor.withValues(alpha: 0.5), + context.primaryColor.withValues(alpha: 0.3), + _gradientAnimation.value, + )!, + Color.lerp( + context.primaryColor.withValues(alpha: 0.2), + context.primaryColor.withValues(alpha: 0.4), + _gradientAnimation.value, + )!, + Color.lerp( + context.primaryColor.withValues(alpha: 0.3), + context.primaryColor.withValues(alpha: 0.5), + _gradientAnimation.value, + )!, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + gradient: LinearGradient( + colors: gradientColors, + stops: const [0.0, 0.5, 1.0], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: context.primaryColor.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Container( + margin: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18.5)), + color: context.colorScheme.surfaceContainerLow, + ), + child: Material( + color: context.colorScheme.surfaceContainerLow, + borderRadius: const BorderRadius.all(Radius.circular(20.5)), + child: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(20.5)), + onTap: () => isCanceling ? null : _onToggle(!_isEnabled), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + context.primaryColor.withValues(alpha: 0.2), + context.primaryColor.withValues(alpha: 0.1), + ], + ), + ), + child: isUploading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Icon( + Icons.cloud_upload_outlined, + color: context.primaryColor, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + "enable_backup".t(context: context), + style: + context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.primaryColor, + ), + ), + ], + ), + if (enqueueCount != enqueueTotalCount) + Text( + "queue_status".t( + context: context, + args: { + 'count': enqueueCount.toString(), + 'total': enqueueTotalCount.toString(), + }, + ), + style: context.textTheme.labelLarge?.copyWith( + color: context.colorScheme.onSurfaceSecondary, + ), + ), + if (isCanceling) + Row( + children: [ + Text( + "canceling".t(), + style: context.textTheme.labelLarge, + ), + const SizedBox(width: 4), + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + backgroundColor: context + .colorScheme.onSurface + .withValues(alpha: 0.2), + ), + ), + ], + ), + ], + ), + ), + Switch.adaptive( + value: _isEnabled, + onChanged: (value) => + isCanceling ? null : _onToggle(value), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 5984160241..3be46d2fbd 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -6,10 +6,12 @@ import 'package:immich_mobile/domain/services/log.service.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/models/backup/backup_state.model.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/backup/backup.provider.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -18,6 +20,7 @@ import 'package:immich_mobile/providers/notification_permission.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart'; +import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; @@ -69,25 +72,18 @@ class AppLifeCycleNotifier extends StateNotifier { } await _ref.read(serverInfoProvider.notifier).getServerVersion(); - - // TODO: Need to decide on how we want to handle uploads once the app is resumed - // await FileDownloader().start(); } if (!Store.isBetaTimelineEnabled) { switch (_ref.read(tabProvider)) { case TabEnum.home: await _ref.read(assetProvider.notifier).getAllAsset(); - break; - case TabEnum.search: - // nothing to do - break; case TabEnum.albums: await _ref.read(albumProvider.notifier).refreshRemoteAlbums(); - break; + case TabEnum.library: - // nothing to do + case TabEnum.search: break; } } else { @@ -108,7 +104,15 @@ class AppLifeCycleNotifier extends StateNotifier { }, ), backgroundManager.syncRemote(), - ]); + ]).then((_) async { + final isEnableBackup = _ref + .read(appSettingsServiceProvider) + .getSetting(AppSettingsEnum.enableBackup); + + if (isEnableBackup) { + await _ref.read(driftBackupProvider.notifier).handleBackupResume(); + } + }); } catch (e, stackTrace) { Logger("AppLifeCycleNotifier").severe( "Error during background sync", diff --git a/mobile/lib/providers/backup/backup_album.provider.dart b/mobile/lib/providers/backup/backup_album.provider.dart index b36d3ac57e..2915c7c216 100644 --- a/mobile/lib/providers/backup/backup_album.provider.dart +++ b/mobile/lib/providers/backup/backup_album.provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/album/local_album.model.dart'; import 'package:immich_mobile/domain/services/local_album.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; final backupAlbumProvider = @@ -18,7 +19,8 @@ class BackupAlbumNotifier extends StateNotifier> { final LocalAlbumService _localAlbumService; Future getAll() async { - state = await _localAlbumService.getAll(); + state = + await _localAlbumService.getAll(sortBy: {SortLocalAlbumsBy.assetCount}); } Future selectAlbum(LocalAlbum album) async { diff --git a/mobile/lib/providers/backup/drift_backup.provider.dart b/mobile/lib/providers/backup/drift_backup.provider.dart index 2544d208c4..c51c40775e 100644 --- a/mobile/lib/providers/backup/drift_backup.provider.dart +++ b/mobile/lib/providers/backup/drift_backup.provider.dart @@ -1,39 +1,75 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'dart:async'; +import 'dart:convert'; import 'package:background_downloader/background_downloader.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; + import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/services/drift_backup.service.dart'; import 'package:immich_mobile/services/upload.service.dart'; +class EnqueueStatus { + final int enqueueCount; + final int totalCount; + + const EnqueueStatus({ + required this.enqueueCount, + required this.totalCount, + }); + + EnqueueStatus copyWith({ + int? enqueueCount, + int? totalCount, + }) { + return EnqueueStatus( + enqueueCount: enqueueCount ?? this.enqueueCount, + totalCount: totalCount ?? this.totalCount, + ); + } + + @override + String toString() => + 'EnqueueStatus(enqueueCount: $enqueueCount, totalCount: $totalCount)'; +} + class DriftUploadStatus { final String taskId; final String filename; final double progress; + final int fileSize; + final String networkSpeedAsString; const DriftUploadStatus({ required this.taskId, required this.filename, required this.progress, + required this.fileSize, + required this.networkSpeedAsString, }); DriftUploadStatus copyWith({ String? taskId, String? filename, double? progress, + int? fileSize, + String? networkSpeedAsString, }) { return DriftUploadStatus( taskId: taskId ?? this.taskId, filename: filename ?? this.filename, progress: progress ?? this.progress, + fileSize: fileSize ?? this.fileSize, + networkSpeedAsString: networkSpeedAsString ?? this.networkSpeedAsString, ); } @override - String toString() => - 'ExpUploadStatus(taskId: $taskId, filename: $filename, progress: $progress)'; + String toString() { + return 'DriftUploadStatus(taskId: $taskId, filename: $filename, progress: $progress, fileSize: $fileSize, networkSpeedAsString: $networkSpeedAsString)'; + } @override bool operator ==(covariant DriftUploadStatus other) { @@ -41,23 +77,65 @@ class DriftUploadStatus { return other.taskId == taskId && other.filename == filename && - other.progress == progress; + other.progress == progress && + other.fileSize == fileSize && + other.networkSpeedAsString == networkSpeedAsString; } @override - int get hashCode => taskId.hashCode ^ filename.hashCode ^ progress.hashCode; + int get hashCode { + return taskId.hashCode ^ + filename.hashCode ^ + progress.hashCode ^ + fileSize.hashCode ^ + networkSpeedAsString.hashCode; + } + + Map toMap() { + return { + 'taskId': taskId, + 'filename': filename, + 'progress': progress, + 'fileSize': fileSize, + 'networkSpeedAsString': networkSpeedAsString, + }; + } + + factory DriftUploadStatus.fromMap(Map map) { + return DriftUploadStatus( + taskId: map['taskId'] as String, + filename: map['filename'] as String, + progress: map['progress'] as double, + fileSize: map['fileSize'] as int, + networkSpeedAsString: map['networkSpeedAsString'] as String, + ); + } + + String toJson() => json.encode(toMap()); + + factory DriftUploadStatus.fromJson(String source) => + DriftUploadStatus.fromMap(json.decode(source) as Map); } class DriftBackupState { final int totalCount; final int backupCount; final int remainderCount; + + final int enqueueCount; + final int enqueueTotalCount; + + final bool isCanceling; + final Map uploadItems; const DriftBackupState({ required this.totalCount, required this.backupCount, required this.remainderCount, + required this.enqueueCount, + required this.enqueueTotalCount, + required this.isCanceling, required this.uploadItems, }); @@ -65,19 +143,25 @@ class DriftBackupState { int? totalCount, int? backupCount, int? remainderCount, + int? enqueueCount, + int? enqueueTotalCount, + bool? isCanceling, Map? uploadItems, }) { return DriftBackupState( totalCount: totalCount ?? this.totalCount, backupCount: backupCount ?? this.backupCount, remainderCount: remainderCount ?? this.remainderCount, + enqueueCount: enqueueCount ?? this.enqueueCount, + enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount, + isCanceling: isCanceling ?? this.isCanceling, uploadItems: uploadItems ?? this.uploadItems, ); } @override String toString() { - return 'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, uploadItems: $uploadItems)'; + return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, uploadItems: $uploadItems)'; } @override @@ -88,6 +172,9 @@ class DriftBackupState { return other.totalCount == totalCount && other.backupCount == backupCount && other.remainderCount == remainderCount && + other.enqueueCount == enqueueCount && + other.enqueueTotalCount == enqueueTotalCount && + other.isCanceling == isCanceling && mapEquals(other.uploadItems, uploadItems); } @@ -96,6 +183,9 @@ class DriftBackupState { return totalCount.hashCode ^ backupCount.hashCode ^ remainderCount.hashCode ^ + enqueueCount.hashCode ^ + enqueueTotalCount.hashCode ^ + isCanceling.hashCode ^ uploadItems.hashCode; } } @@ -117,6 +207,9 @@ class ExpBackupNotifier extends StateNotifier { totalCount: 0, backupCount: 0, remainderCount: 0, + enqueueCount: 0, + enqueueTotalCount: 0, + isCanceling: false, uploadItems: {}, ), ) { @@ -131,13 +224,39 @@ class ExpBackupNotifier extends StateNotifier { StreamSubscription? _statusSubscription; StreamSubscription? _progressSubscription; + /// Remove upload item from state + void _removeUploadItem(String taskId) { + if (state.uploadItems.containsKey(taskId)) { + final updatedItems = + Map.from(state.uploadItems); + updatedItems.remove(taskId); + state = state.copyWith(uploadItems: updatedItems); + } + } + void _handleTaskStatusUpdate(TaskStatusUpdate update) { switch (update.status) { case TaskStatus.complete: - state = state.copyWith( - backupCount: state.backupCount + 1, - remainderCount: state.remainderCount - 1, - ); + if (update.task.group == kBackupGroup) { + state = state.copyWith( + backupCount: state.backupCount + 1, + remainderCount: state.remainderCount - 1, + ); + } + + // Remove the completed task from the upload items + final taskId = update.task.taskId; + if (state.uploadItems.containsKey(taskId)) { + Future.delayed(const Duration(milliseconds: 500), () { + _removeUploadItem(taskId); + }); + } + + case TaskStatus.failed: + break; + + case TaskStatus.canceled: + _removeUploadItem(update.task.taskId); break; default: @@ -145,7 +264,48 @@ class ExpBackupNotifier extends StateNotifier { } } - void _handleTaskProgressUpdate(TaskProgressUpdate update) {} + void _handleTaskProgressUpdate(TaskProgressUpdate update) { + final taskId = update.task.taskId; + final filename = update.task.displayName; + final progress = update.progress; + final currentItem = state.uploadItems[taskId]; + if (currentItem != null) { + if (progress == kUploadStatusCanceled) { + _removeUploadItem(update.task.taskId); + return; + } + + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + taskId: update.hasExpectedFileSize + ? currentItem.copyWith( + progress: progress, + fileSize: update.expectedFileSize, + networkSpeedAsString: update.networkSpeedAsString, + ) + : currentItem.copyWith( + progress: progress, + ), + }, + ); + + return; + } + + state = state.copyWith( + uploadItems: { + ...state.uploadItems, + taskId: DriftUploadStatus( + taskId: taskId, + filename: filename, + progress: progress, + fileSize: update.expectedFileSize, + networkSpeedAsString: update.networkSpeedAsString, + ), + }, + ); + } Future getBackupStatus() async { final [totalCount, backupCount, remainderCount] = await Future.wait([ @@ -162,27 +322,50 @@ class ExpBackupNotifier extends StateNotifier { } Future backup() { - return _backupService.backup(); + return _backupService.backup(_updateEnqueueCount); + } + + void _updateEnqueueCount(EnqueueStatus status) { + state = state.copyWith( + enqueueCount: status.enqueueCount, + enqueueTotalCount: status.totalCount, + ); } Future cancel() async { + state = state.copyWith( + enqueueCount: 0, + enqueueTotalCount: 0, + isCanceling: true, + ); + await _backupService.cancel(); - await getDataInfo(); + + // Check if there are any tasks left in the queue + final tasks = await FileDownloader().allTasks(group: kBackupGroup); + + debugPrint("Tasks left to cancel: ${tasks.length}"); + + if (tasks.isNotEmpty) { + await cancel(); + } else { + // Clear all upload items when cancellation is complete + state = state.copyWith( + isCanceling: false, + uploadItems: {}, + ); + } } - Future getDataInfo() async { - final a = await FileDownloader().database.allRecordsWithStatus( - TaskStatus.enqueued, - group: kBackupGroup, - ); + Future handleBackupResume() async { + final tasks = await FileDownloader().allTasks(group: kBackupGroup); + if (tasks.isEmpty) { + // Start a new backup queue + await backup(); + } - final b = await FileDownloader().allTasks( - group: kBackupGroup, - ); - - debugPrint( - "Enqueued tasks: ${a.length}, All tasks: ${b.length}", - ); + debugPrint("Tasks to resume: ${tasks.length}"); + await FileDownloader().start(); } @override diff --git a/mobile/lib/providers/websocket.provider.dart b/mobile/lib/providers/websocket.provider.dart index 05fe5a087c..2718738286 100644 --- a/mobile/lib/providers/websocket.provider.dart +++ b/mobile/lib/providers/websocket.provider.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/models/server_info/server_version.model.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/auth.provider.dart'; import 'package:immich_mobile/providers/background_sync.provider.dart'; +// import 'package:immich_mobile/providers/background_sync.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -177,9 +178,15 @@ class WebsocketNotifier extends StateNotifier { }); if (!Store.isBetaTimelineEnabled) { - startListeningToOldEvents(); + socket.on('on_upload_success', _handleOnUploadSuccess); + socket.on('on_asset_delete', _handleOnAssetDelete); + socket.on('on_asset_trash', _handleOnAssetTrash); + socket.on('on_asset_restore', _handleServerUpdates); + socket.on('on_asset_update', _handleServerUpdates); + socket.on('on_asset_stack_update', _handleServerUpdates); + socket.on('on_asset_hidden', _handleOnAssetHidden); } else { - startListeningToBetaEvents(); + socket.on('AssetUploadReadyV1', _handleSyncAssetUploadReady); } socket.on('on_config_update', _handleOnConfigUpdate); @@ -207,7 +214,6 @@ class WebsocketNotifier extends StateNotifier { } void stopListenToEvent(String eventName) { - debugPrint("Stop listening to event $eventName"); state.socket?.off(eventName); } diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index d7dd45dbd9..d0f8852dc3 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -27,6 +27,7 @@ import 'package:immich_mobile/pages/backup/drift_backup.page.dart'; import 'package:immich_mobile/pages/backup/backup_album_selection.page.dart'; import 'package:immich_mobile/pages/backup/backup_controller.page.dart'; import 'package:immich_mobile/pages/backup/backup_options.page.dart'; +import 'package:immich_mobile/pages/backup/drift_upload_detail.page.dart'; import 'package:immich_mobile/pages/backup/failed_backup_status.page.dart'; import 'package:immich_mobile/pages/common/activities.page.dart'; import 'package:immich_mobile/pages/common/app_log.page.dart'; @@ -484,6 +485,10 @@ class AppRouter extends RootStackRouter { page: ChangeExperienceRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: DriftUploadDetailRoute.page, + guards: [_authGuard, _duplicateGuard], + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index c72dc62765..c9ed8a40a3 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -1078,6 +1078,22 @@ class DriftTrashRoute extends PageRouteInfo { ); } +/// generated route for +/// [DriftUploadDetailPage] +class DriftUploadDetailRoute extends PageRouteInfo { + const DriftUploadDetailRoute({List? children}) + : super(DriftUploadDetailRoute.name, initialChildren: children); + + static const String name = 'DriftUploadDetailRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftUploadDetailPage(); + }, + ); +} + /// generated route for /// [DriftUserSelectionPage] class DriftUserSelectionRoute diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index 982dab1cae..cefc52385a 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -91,6 +91,7 @@ enum AppSettingsEnum { true, ), betaTimeline(StoreKey.betaTimeline, null, false), + enableBackup(StoreKey.enableBackup, null, false), ; const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/services/drift_backup.service.dart b/mobile/lib/services/drift_backup.service.dart index 5966aad304..2f51c261fb 100644 --- a/mobile/lib/services/drift_backup.service.dart +++ b/mobile/lib/services/drift_backup.service.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/providers/backup/drift_backup.provider.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart'; import 'package:immich_mobile/services/upload.service.dart'; @@ -55,7 +56,9 @@ class DriftBackupService { return _backupRepository.getBackupCount(); } - Future backup() async { + Future backup( + void Function(EnqueueStatus status) onEnqueueTasks, + ) async { shouldCancel = false; final candidates = await _backupRepository.getCandidates(); @@ -83,8 +86,12 @@ class DriftBackupService { if (tasks.isNotEmpty && !shouldCancel) { count += tasks.length; _uploadService.enqueueTasks(tasks); - debugPrint( - "Enqueued $count/${candidates.length} tasks for backup", + + onEnqueueTasks( + EnqueueStatus( + enqueueCount: count, + totalCount: candidates.length, + ), ); } } @@ -213,7 +220,7 @@ class DriftBackupService { deviceAssetId: asset.id, fields: fields, group: kBackupLivePhotoGroup, - priority: 0, + priority: 0, // Highest priority to get upload immediately ); } diff --git a/mobile/lib/services/upload.service.dart b/mobile/lib/services/upload.service.dart index 60aab4a16c..b869624e52 100644 --- a/mobile/lib/services/upload.service.dart +++ b/mobile/lib/services/upload.service.dart @@ -134,6 +134,7 @@ class UploadService { group: group, priority: priority ?? 5, updates: Updates.statusAndProgress, + retries: 3, ); } } diff --git a/mobile/lib/widgets/common/immich_sliver_app_bar.dart b/mobile/lib/widgets/common/immich_sliver_app_bar.dart index 2b020d20ee..c7ddeca6e0 100644 --- a/mobile/lib/widgets/common/immich_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/immich_sliver_app_bar.dart @@ -1,5 +1,6 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -83,6 +84,11 @@ class ImmichSliverAppBar extends ConsumerWidget { child: action, ), ), + if (kDebugMode || kProfileMode) + IconButton( + icon: const Icon(Icons.science_rounded), + onPressed: () => context.pushRoute(const FeatInDevRoute()), + ), if (showUploadButton) const Padding( padding: EdgeInsets.only(right: 20), diff --git a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart index 1de3a6e7ab..a9c873cb67 100644 --- a/mobile/lib/widgets/settings/beta_timeline_list_tile.dart +++ b/mobile/lib/widgets/settings/beta_timeline_list_tile.dart @@ -90,6 +90,19 @@ class _BetaTimelineListTileState extends ConsumerState ), actions: [ TextButton( + onPressed: () { + context.pop(); + }, + child: Text( + "cancel".t(context: context), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: context.colorScheme.outline, + ), + ), + ), + ElevatedButton( onPressed: () async { Navigator.of(context).pop(); await ref.read(appSettingsServiceProvider).setSetting( @@ -101,25 +114,7 @@ class _BetaTimelineListTileState extends ConsumerState ); }, child: Text( - "YES", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: context.primaryColor, - ), - ), - ), - TextButton( - onPressed: () { - context.pop(); - }, - child: Text( - "NO", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: context.colorScheme.outline, - ), + "ok".t(context: context), ), ), ], @@ -135,13 +130,13 @@ class _BetaTimelineListTileState extends ConsumerState _gradientAnimation.value, )!, Color.lerp( - context.primaryColor.withValues(alpha: 0.4), - context.primaryColor.withValues(alpha: 0.6), + context.logoPink.withValues(alpha: 0.2), + context.logoPink.withValues(alpha: 0.4), _gradientAnimation.value, )!, Color.lerp( - context.primaryColor.withValues(alpha: 0.3), - context.primaryColor.withValues(alpha: 0.5), + context.logoRed.withValues(alpha: 0.3), + context.logoRed.withValues(alpha: 0.5), _gradientAnimation.value, )!, ]; @@ -155,7 +150,7 @@ class _BetaTimelineListTileState extends ConsumerState stops: const [0.0, 0.5, 1.0], begin: Alignment.topLeft, end: Alignment.bottomRight, - transform: GradientRotation(_rotationAnimation.value * 0.1), + transform: GradientRotation(_rotationAnimation.value * 0.5), ), boxShadow: [ BoxShadow( @@ -166,13 +161,12 @@ class _BetaTimelineListTileState extends ConsumerState ], ), child: Container( - margin: const EdgeInsets.all(1.5), + margin: const EdgeInsets.all(2), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10.5)), color: context.scaffoldBackgroundColor, ), child: Material( - color: Colors.transparent, borderRadius: const BorderRadius.all(Radius.circular(10.5)), child: InkWell( borderRadius: const BorderRadius.all(Radius.circular(10.5)), @@ -205,7 +199,7 @@ class _BetaTimelineListTileState extends ConsumerState ), ), ), - const SizedBox(width: 16), + const SizedBox(width: 28), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -259,8 +253,9 @@ class _BetaTimelineListTileState extends ConsumerState .t(context: context), style: context.textTheme.labelLarge?.copyWith( color: context.textTheme.labelLarge?.color - ?.withValues(alpha: 0.7), + ?.withValues(alpha: 0.9), ), + maxLines: 2, ), ], ), From 7ea878359367f9076fd64fe28d8b9f12d350b84e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 22 Jul 2025 03:18:22 +0530 Subject: [PATCH 40/45] chore: do not render drift migration auto gen files (#20060) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .gitattributes | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitattributes b/.gitattributes index 3d43ff20ed..e3fb061bbc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,6 +12,12 @@ mobile/lib/**/*.drift.dart linguist-generated=true mobile/drift_schemas/main/drift_schema_*.json -diff -merge mobile/drift_schemas/main/drift_schema_*.json linguist-generated=true +mobile/lib/infrastructure/repositories/db.repository.steps.dart -diff -merge +mobile/lib/infrastructure/repositories/db.repository.steps.dart linguist-generated=true + +mobile/test/drift/main/generated/** -diff -merge +mobile/test/drift/main/generated/** linguist-generated=true + open-api/typescript-sdk/fetch-client.ts -diff -merge open-api/typescript-sdk/fetch-client.ts linguist-generated=true From 99e5b33969bcb172f26ca72406d4e081987c3ec8 Mon Sep 17 00:00:00 2001 From: Brandon Wees Date: Mon, 21 Jul 2025 14:51:16 -0700 Subject: [PATCH 41/45] feat: add favorites support to android widget (#20061) --- .../app/alextran/immich/widget/ImageDownloadWorker.kt | 11 +++++++---- .../immich/widget/configure/RandomConfigure.kt | 2 +- .../kotlin/app/alextran/immich/widget/model/Model.kt | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt index 2a23d0ecbd..3915f291f8 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/ImageDownloadWorker.kt @@ -169,13 +169,16 @@ class ImageDownloadWorker( ): WidgetEntry { val api = ImmichAPI(serverConfig) - val filters = SearchFilters(AssetType.IMAGE) + val filters = SearchFilters() val albumId = widgetConfig[kSelectedAlbum] val showSubtitle = widgetConfig[kShowAlbumName] val albumName = widgetConfig[kSelectedAlbumName] var subtitle: String? = if (showSubtitle == true) albumName else "" - if (albumId != null) { + + if (albumId == "FAVORITES") { + filters.isFavorite = true + } else if (albumId != null) { filters.albumIds = listOf(albumId) } @@ -183,7 +186,7 @@ class ImageDownloadWorker( // handle an empty album, fallback to random if (randomSearch.isEmpty() && albumId != null) { - randomSearch = api.fetchSearchResults(SearchFilters(AssetType.IMAGE)) + randomSearch = api.fetchSearchResults(SearchFilters()) subtitle = "" } @@ -215,7 +218,7 @@ class ImageDownloadWorker( val yearDiff = today.year - memory.data.year subtitle = "$yearDiff ${if (yearDiff == 1) "year" else "years"} ago" } else { - val filters = SearchFilters(AssetType.IMAGE, size=1) + val filters = SearchFilters(size=1) asset = api.fetchSearchResults(filters).first() } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt index d0490c023e..83e404a8f1 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/configure/RandomConfigure.kt @@ -98,7 +98,7 @@ fun RandomConfiguration(context: Context, appWidgetId: Int, glanceId: GlanceId, albumItems = listOf(DropdownItem(currentAlbumName, currentAlbumId)) } - availableAlbums = listOf(DropdownItem("None", "NONE")) + albumItems + availableAlbums = listOf(DropdownItem("None", "NONE"), DropdownItem("Favorites", "FAVORITES")) + albumItems // load selected configuration val albumEntity = availableAlbums.firstOrNull { it.id == currentAlbumId } diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt index 2337de0612..9595a3b696 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/widget/model/Model.kt @@ -17,7 +17,8 @@ data class Asset( data class SearchFilters( var type: AssetType = AssetType.IMAGE, val size: Int = 1, - var albumIds: List = listOf() + var albumIds: List = listOf(), + var isFavorite: Boolean? = null ) data class MemoryResult( From 737e768212d1bb758b74ada600a83a9a922f7f1c Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Jul 2025 16:58:53 -0500 Subject: [PATCH 42/45] feat: drift partners (#20051) * feat: drift toggle partner in timeline * partners operation * fix: lint --- mobile/lib/domain/models/user.model.dart | 88 ++++++++++ .../lib/domain/services/partner.service.dart | 61 +++++++ .../repositories/partner.repository.dart | 164 ++++++++++++++++++ .../library/partner/drift_partner.page.dart | 161 +++++++++++++++++ .../pages/drift_library.page.dart | 16 +- .../pages/drift_partner_detail.page.dart | 64 +++++-- .../widgets/partner_user_avatar.widget.dart | 32 ++++ .../infrastructure/partner.provider.dart | 92 ++++++++++ .../infrastructure/user.provider.dart | 22 +++ mobile/lib/routing/router.dart | 6 + mobile/lib/routing/router.gr.dart | 20 ++- 11 files changed, 703 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/domain/services/partner.service.dart create mode 100644 mobile/lib/infrastructure/repositories/partner.repository.dart create mode 100644 mobile/lib/pages/library/partner/drift_partner.page.dart create mode 100644 mobile/lib/presentation/widgets/partner_user_avatar.widget.dart create mode 100644 mobile/lib/providers/infrastructure/partner.provider.dart diff --git a/mobile/lib/domain/models/user.model.dart b/mobile/lib/domain/models/user.model.dart index abf2e5620b..6cafd8d149 100644 --- a/mobile/lib/domain/models/user.model.dart +++ b/mobile/lib/domain/models/user.model.dart @@ -1,3 +1,6 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + import 'package:immich_mobile/domain/models/user_metadata.model.dart'; // TODO: Rename to User once Isar is removed @@ -123,3 +126,88 @@ quotaSizeInBytes: $quotaSizeInBytes, quotaUsageInBytes.hashCode ^ quotaSizeInBytes.hashCode; } + +class PartnerUserDto { + final String id; + final String email; + final String name; + final bool inTimeline; + + final String? profileImagePath; + + const PartnerUserDto({ + required this.id, + required this.email, + required this.name, + required this.inTimeline, + this.profileImagePath, + }); + + PartnerUserDto copyWith({ + String? id, + String? email, + String? name, + bool? inTimeline, + String? profileImagePath, + }) { + return PartnerUserDto( + id: id ?? this.id, + email: email ?? this.email, + name: name ?? this.name, + inTimeline: inTimeline ?? this.inTimeline, + profileImagePath: profileImagePath ?? this.profileImagePath, + ); + } + + Map toMap() { + return { + 'id': id, + 'email': email, + 'name': name, + 'inTimeline': inTimeline, + 'profileImagePath': profileImagePath, + }; + } + + factory PartnerUserDto.fromMap(Map map) { + return PartnerUserDto( + id: map['id'] as String, + email: map['email'] as String, + name: map['name'] as String, + inTimeline: map['inTimeline'] as bool, + profileImagePath: map['profileImagePath'] != null + ? map['profileImagePath'] as String + : null, + ); + } + + String toJson() => json.encode(toMap()); + + factory PartnerUserDto.fromJson(String source) => + PartnerUserDto.fromMap(json.decode(source) as Map); + + @override + String toString() { + return 'PartnerUserDto(id: $id, email: $email, name: $name, inTimeline: $inTimeline, profileImagePath: $profileImagePath)'; + } + + @override + bool operator ==(covariant PartnerUserDto other) { + if (identical(this, other)) return true; + + return other.id == id && + other.email == email && + other.name == name && + other.inTimeline == inTimeline && + other.profileImagePath == profileImagePath; + } + + @override + int get hashCode { + return id.hashCode ^ + email.hashCode ^ + name.hashCode ^ + inTimeline.hashCode ^ + profileImagePath.hashCode; + } +} diff --git a/mobile/lib/domain/services/partner.service.dart b/mobile/lib/domain/services/partner.service.dart new file mode 100644 index 0000000000..065560c4be --- /dev/null +++ b/mobile/lib/domain/services/partner.service.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; + +class DriftPartnerService { + final DriftPartnerRepository _driftPartnerRepository; + final PartnerApiRepository _partnerApiRepository; + + const DriftPartnerService( + this._driftPartnerRepository, + this._partnerApiRepository, + ); + + Future> getSharedWith(String userId) { + return _driftPartnerRepository.getSharedWith(userId); + } + + Future> getSharedBy(String userId) { + return _driftPartnerRepository.getSharedBy(userId); + } + + Future> getAvailablePartners( + String currentUserId, + ) async { + final otherUsers = + await _driftPartnerRepository.getAvailablePartners(currentUserId); + final currentPartners = + await _driftPartnerRepository.getSharedBy(currentUserId); + final available = otherUsers.where((user) { + return !currentPartners.any((partner) => partner.id == user.id); + }).toList(); + + return available; + } + + Future toggleShowInTimeline(String partnerId, String userId) async { + final partner = await _driftPartnerRepository.getPartner(partnerId, userId); + if (partner == null) { + debugPrint("Partner not found: $partnerId for user: $userId"); + return; + } + + await _partnerApiRepository.update( + partnerId, + inTimeline: !partner.inTimeline, + ); + + await _driftPartnerRepository.toggleShowInTimeline(partner, userId); + } + + Future addPartner(String partnerId, String userId) async { + await _partnerApiRepository.create(partnerId); + await _driftPartnerRepository.create(partnerId, userId); + } + + Future removePartner(String partnerId, String userId) async { + await _partnerApiRepository.delete(partnerId); + await _driftPartnerRepository.delete(partnerId, userId); + } +} diff --git a/mobile/lib/infrastructure/repositories/partner.repository.dart b/mobile/lib/infrastructure/repositories/partner.repository.dart new file mode 100644 index 0000000000..b3b057b035 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/partner.repository.dart @@ -0,0 +1,164 @@ +import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; + +class DriftPartnerRepository extends DriftDatabaseRepository { + final Drift _db; + const DriftPartnerRepository(this._db) : super(_db); + + Future> getPartners(String userId) { + final query = _db.select(_db.partnerEntity).join([ + innerJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById), + ), + ]) + ..where( + _db.partnerEntity.sharedWithId.equals(userId), + ); + + return query.map((row) { + final user = row.readTable(_db.userEntity); + final partner = row.readTable(_db.partnerEntity); + return PartnerUserDto( + id: user.id, + email: user.email, + name: user.name, + inTimeline: partner.inTimeline, + ); + }).get(); + } + + // Get users who we can share our library with + Future> getAvailablePartners(String currentUserId) { + final query = _db.select(_db.userEntity) + ..where((row) => row.id.equals(currentUserId).not()); + + return query.map((user) { + return PartnerUserDto( + id: user.id, + email: user.email, + name: user.name, + inTimeline: false, + ); + }).get(); + } + + // Get users who are sharing their photos WITH the current user + Future> getSharedWith(String partnerId) { + final query = _db.select(_db.partnerEntity).join([ + innerJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById), + ), + ]) + ..where( + _db.partnerEntity.sharedWithId.equals(partnerId), + ); + + return query.map((row) { + final user = row.readTable(_db.userEntity); + final partner = row.readTable(_db.partnerEntity); + return PartnerUserDto( + id: user.id, + email: user.email, + name: user.name, + inTimeline: partner.inTimeline, + ); + }).get(); + } + + // Get users who the current user is sharing their photos TO + Future> getSharedBy(String userId) { + final query = _db.select(_db.partnerEntity).join([ + innerJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.partnerEntity.sharedWithId), + ), + ]) + ..where( + _db.partnerEntity.sharedById.equals(userId), + ); + + return query.map((row) { + final user = row.readTable(_db.userEntity); + final partner = row.readTable(_db.partnerEntity); + return PartnerUserDto( + id: user.id, + email: user.email, + name: user.name, + inTimeline: partner.inTimeline, + ); + }).get(); + } + + Future> getAllPartnerIds(String userId) async { + // Get users who are sharing with me (sharedWithId = userId) + final sharingWithMeQuery = _db.select(_db.partnerEntity) + ..where((tbl) => tbl.sharedWithId.equals(userId)); + final sharingWithMe = + await sharingWithMeQuery.map((row) => row.sharedById).get(); + + // Get users who I am sharing with (sharedById = userId) + final sharingWithThemQuery = _db.select(_db.partnerEntity) + ..where((tbl) => tbl.sharedById.equals(userId)); + final sharingWithThem = + await sharingWithThemQuery.map((row) => row.sharedWithId).get(); + + // Combine both lists and remove duplicates + final allPartnerIds = + {...sharingWithMe, ...sharingWithThem}.toList(); + return allPartnerIds; + } + + Future getPartner(String partnerId, String userId) { + final query = _db.select(_db.partnerEntity).join([ + innerJoin( + _db.userEntity, + _db.userEntity.id.equalsExp(_db.partnerEntity.sharedById), + ), + ]) + ..where( + _db.partnerEntity.sharedById.equals(partnerId) & + _db.partnerEntity.sharedWithId.equals(userId), + ); + + return query.map((row) { + final user = row.readTable(_db.userEntity); + final partner = row.readTable(_db.partnerEntity); + return PartnerUserDto( + id: user.id, + email: user.email, + name: user.name, + inTimeline: partner.inTimeline, + ); + }).getSingleOrNull(); + } + + Future toggleShowInTimeline(PartnerUserDto partner, String userId) { + return _db.partnerEntity.update().replace( + PartnerEntityCompanion( + sharedById: Value(partner.id), + sharedWithId: Value(userId), + inTimeline: Value(!partner.inTimeline), + ), + ); + } + + Future create(String partnerId, String userId) { + final entity = PartnerEntityCompanion( + sharedById: Value(userId), + sharedWithId: Value(partnerId), + inTimeline: const Value(false), + ); + + return _db.partnerEntity.insertOne(entity); + } + + Future delete(String partnerId, String userId) { + return _db.partnerEntity.deleteWhere( + (t) => t.sharedById.equals(userId) & t.sharedWithId.equals(partnerId), + ); + } +} diff --git a/mobile/lib/pages/library/partner/drift_partner.page.dart b/mobile/lib/pages/library/partner/drift_partner.page.dart new file mode 100644 index 0000000000..04efbe066c --- /dev/null +++ b/mobile/lib/pages/library/partner/drift_partner.page.dart @@ -0,0 +1,161 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/partner_user_avatar.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/widgets/common/confirm_dialog.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; + +@RoutePage() +class DriftPartnerPage extends HookConsumerWidget { + const DriftPartnerPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final potentialPartnersAsync = ref.watch(driftAvailablePartnerProvider); + + addNewUsersHandler() async { + final potentialPartners = potentialPartnersAsync.value; + if (potentialPartners == null || potentialPartners.isEmpty) { + ImmichToast.show( + context: context, + msg: "partner_page_no_more_users".tr(), + ); + return; + } + + final selectedUser = await showDialog( + context: context, + builder: (context) { + return SimpleDialog( + title: const Text("partner_page_select_partner").tr(), + children: [ + for (PartnerUserDto partner in potentialPartners) + SimpleDialogOption( + onPressed: () => context.pop(partner), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8), + child: PartnerUserAvatar(partner: partner), + ), + Text(partner.name), + ], + ), + ), + ], + ); + }, + ); + if (selectedUser != null) { + await ref.read(partnerUsersProvider.notifier).addPartner(selectedUser); + } + } + + onDeleteUser(PartnerUserDto partner) { + return showDialog( + context: context, + builder: (BuildContext context) { + return ConfirmDialog( + title: "stop_photo_sharing", + content: "partner_page_stop_sharing_content" + .tr(namedArgs: {'partner': partner.name}), + onOk: () => + ref.read(partnerUsersProvider.notifier).removePartner(partner), + ); + }, + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text("partners").t(context: context), + elevation: 0, + centerTitle: false, + actions: [ + IconButton( + onPressed: potentialPartnersAsync.whenOrNull( + data: (data) => addNewUsersHandler, + ), + icon: const Icon(Icons.person_add), + tooltip: "add_partner".tr(), + ), + ], + ), + body: _SharedToPartnerList( + onAddPartner: addNewUsersHandler, + onDeletePartner: onDeleteUser, + ), + ); + } +} + +class _SharedToPartnerList extends ConsumerWidget { + final VoidCallback onAddPartner; + final Function(PartnerUserDto partner) onDeletePartner; + + const _SharedToPartnerList({ + required this.onAddPartner, + required this.onDeletePartner, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final partnerAsync = ref.watch(driftSharedByPartnerProvider); + + return partnerAsync.when( + data: (partners) { + if (partners.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: const Text( + "partner_page_empty_message", + style: TextStyle(fontSize: 14), + ).tr(), + ), + Align( + alignment: Alignment.center, + child: ElevatedButton.icon( + onPressed: onAddPartner, + icon: const Icon(Icons.person_add), + label: const Text("add_partner").tr(), + ), + ), + ], + ), + ); + } + + return ListView.builder( + itemCount: partners.length, + itemBuilder: (context, index) { + final partner = partners[index]; + return ListTile( + leading: PartnerUserAvatar(partner: partner), + title: Text(partner.name), + subtitle: Text(partner.email), + trailing: IconButton( + icon: const Icon(Icons.person_remove), + onPressed: () => onDeletePartner(partner), + ), + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center( + child: Text("Error loading partners: $error"), + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 552733980e..eba0a5ea81 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -6,15 +6,15 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart'; +import 'package:immich_mobile/presentation/widgets/partner_user_avatar.widget.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; -import 'package:immich_mobile/providers/partner.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/search/people.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; -import 'package:immich_mobile/widgets/common/user_avatar.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; @@ -391,7 +391,8 @@ class _QuickAccessButtonList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final partners = ref.watch(partnerSharedWithProvider); + final partnerSharedWithAsync = ref.watch(driftSharedWithPartnerProvider); + final partners = partnerSharedWithAsync.valueOrNull ?? []; return SliverPadding( padding: const EdgeInsets.only(left: 16, top: 12, right: 16, bottom: 32), @@ -452,7 +453,6 @@ class _QuickAccessButtonList extends ConsumerWidget { fontWeight: FontWeight.w500, ), ), - // TODO: PIN code is needed onTap: () => context.pushRoute(const DriftLockedFolderRoute()), ), ListTile( @@ -466,7 +466,7 @@ class _QuickAccessButtonList extends ConsumerWidget { fontWeight: FontWeight.w500, ), ), - onTap: () => context.pushRoute(const PartnerRoute()), + onTap: () => context.pushRoute(const DriftPartnerRoute()), ), _PartnerList(partners: partners), ], @@ -480,7 +480,7 @@ class _QuickAccessButtonList extends ConsumerWidget { class _PartnerList extends StatelessWidget { const _PartnerList({required this.partners}); - final List partners; + final List partners; @override Widget build(BuildContext context) { @@ -503,7 +503,9 @@ class _PartnerList extends StatelessWidget { left: 12.0, right: 18.0, ), - leading: userAvatar(context, partner, radius: 16), + leading: PartnerUserAvatar( + partner: partner, + ), title: const Text( "partner_list_user_photos", style: TextStyle( diff --git a/mobile/lib/presentation/pages/drift_partner_detail.page.dart b/mobile/lib/presentation/pages/drift_partner_detail.page.dart index baae893d39..6c77a480ea 100644 --- a/mobile/lib/presentation/pages/drift_partner_detail.page.dart +++ b/mobile/lib/presentation/pages/drift_partner_detail.page.dart @@ -6,11 +6,14 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/partner_detail_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; @RoutePage() class DriftPartnerDetailPage extends StatelessWidget { - final UserDto partner; + final PartnerUserDto partner; const DriftPartnerDetailPage({ super.key, @@ -35,12 +38,7 @@ class DriftPartnerDetailPage extends StatelessWidget { title: partner.name, icon: Icons.person_outline, ), - topSliverWidget: _InfoBox( - onTap: () => { - // TODO: Create DriftUserProvider/DriftUserService to handle this action - }, - inTimeline: partner.inTimeline, - ), + topSliverWidget: _InfoBox(partner: partner), topSliverWidgetHeight: 110, bottomSheet: const PartnerDetailBottomSheet(), ), @@ -48,15 +46,53 @@ class DriftPartnerDetailPage extends StatelessWidget { } } -class _InfoBox extends StatelessWidget { - final VoidCallback onTap; - final bool inTimeline; +class _InfoBox extends ConsumerStatefulWidget { + final PartnerUserDto partner; const _InfoBox({ - required this.onTap, - required this.inTimeline, + required this.partner, }); + @override + ConsumerState<_InfoBox> createState() => _InfoBoxState(); +} + +class _InfoBoxState extends ConsumerState<_InfoBox> { + bool _inTimeline = false; + + @override + void initState() { + super.initState(); + _inTimeline = widget.partner.inTimeline; + } + + _toggleInTimeline() async { + final user = ref.read(currentUserProvider); + if (user == null) { + return; + } + + try { + await ref.read(partnerUsersProvider.notifier).toggleShowInTimeline( + widget.partner.id, + user.id, + ); + + setState(() { + _inTimeline = !_inTimeline; + }); + } catch (error, stack) { + debugPrint("Failed to toggle in timeline: $error $stack"); + ImmichToast.show( + context: context, + toastType: ToastType.error, + durationInSecond: 1, + msg: "Failed to toggle the timeline setting", + ); + return; + } + } + @override Widget build(BuildContext context) { return SliverToBoxAdapter( @@ -96,8 +132,8 @@ class _InfoBox extends StatelessWidget { style: context.textTheme.bodyMedium, ), trailing: Switch( - value: inTimeline, - onChanged: (_) => onTap(), + value: _inTimeline, + onChanged: (_) => _toggleInTimeline(), ), ), ), diff --git a/mobile/lib/presentation/widgets/partner_user_avatar.widget.dart b/mobile/lib/presentation/widgets/partner_user_avatar.widget.dart new file mode 100644 index 0000000000..9be55cae67 --- /dev/null +++ b/mobile/lib/presentation/widgets/partner_user_avatar.widget.dart @@ -0,0 +1,32 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/store.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/entities/store.entity.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/services/api.service.dart'; + +class PartnerUserAvatar extends StatelessWidget { + const PartnerUserAvatar({super.key, required this.partner}); + + final PartnerUserDto partner; + + @override + Widget build(BuildContext context) { + final url = + "${Store.get(StoreKey.serverEndpoint)}/users/${partner.id}/profile-image"; + final nameFirstLetter = partner.name.isNotEmpty ? partner.name[0] : ""; + return CircleAvatar( + radius: 16, + backgroundColor: context.primaryColor.withAlpha(50), + foregroundImage: CachedNetworkImageProvider( + url, + headers: ApiService.getRequestHeaders(), + cacheKey: "user-${partner.id}-profile", + ), + // silence errors if user has no profile image, use initials as fallback + onForegroundImageError: (exception, stackTrace) {}, + child: Text(nameFirstLetter.toUpperCase()), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/partner.provider.dart b/mobile/lib/providers/infrastructure/partner.provider.dart new file mode 100644 index 0000000000..e1c7ebf960 --- /dev/null +++ b/mobile/lib/providers/infrastructure/partner.provider.dart @@ -0,0 +1,92 @@ +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/services/partner.service.dart'; +import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class PartnerNotifier extends Notifier> { + late DriftPartnerService _driftPartnerService; + + @override + List build() { + _driftPartnerService = ref.read(driftPartnerServiceProvider); + return []; + } + + Future _loadPartners() async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + + state = await _driftPartnerService.getSharedWith(currentUser.id); + } + + Future> getPartners(String userId) async { + final partners = await _driftPartnerService.getSharedWith(userId); + state = partners; + return partners; + } + + Future toggleShowInTimeline(String partnerId, String userId) async { + await _driftPartnerService.toggleShowInTimeline(partnerId, userId); + await _loadPartners(); + } + + Future addPartner(PartnerUserDto partner) async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + + await _driftPartnerService.addPartner(partner.id, currentUser.id); + await _loadPartners(); + ref.invalidate(driftAvailablePartnerProvider); + ref.invalidate(driftSharedByPartnerProvider); + } + + Future removePartner(PartnerUserDto partner) async { + final currentUser = ref.read(currentUserProvider); + if (currentUser == null) { + return; + } + + await _driftPartnerService.removePartner(partner.id, currentUser.id); + await _loadPartners(); + ref.invalidate(driftAvailablePartnerProvider); + ref.invalidate(driftSharedByPartnerProvider); + } +} + +final driftAvailablePartnerProvider = + FutureProvider.autoDispose>((ref) { + final currentUser = ref.watch(currentUserProvider); + if (currentUser == null) { + return []; + } + + return ref + .watch(driftPartnerServiceProvider) + .getAvailablePartners(currentUser.id); +}); + +final driftSharedByPartnerProvider = + FutureProvider.autoDispose>((ref) { + final currentUser = ref.watch(currentUserProvider); + if (currentUser == null) { + return []; + } + + return ref.watch(driftPartnerServiceProvider).getSharedBy(currentUser.id); +}); + +final driftSharedWithPartnerProvider = + FutureProvider.autoDispose>((ref) { + final currentUser = ref.watch(currentUserProvider); + if (currentUser == null) { + return []; + } + + return ref.watch(driftPartnerServiceProvider).getSharedWith(currentUser.id); +}); diff --git a/mobile/lib/providers/infrastructure/user.provider.dart b/mobile/lib/providers/infrastructure/user.provider.dart index ca65f8be14..d328f97600 100644 --- a/mobile/lib/providers/infrastructure/user.provider.dart +++ b/mobile/lib/providers/infrastructure/user.provider.dart @@ -1,10 +1,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/services/partner.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/partner.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/partner.provider.dart'; import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; +import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'user.provider.g.dart'; @@ -23,3 +28,20 @@ UserService userService(Ref ref) => UserService( userApiRepository: ref.watch(userApiRepositoryProvider), storeService: ref.watch(storeServiceProvider), ); + +/// Drifts +final driftPartnerRepositoryProvider = Provider( + (ref) => DriftPartnerRepository(ref.watch(driftProvider)), +); + +final driftPartnerServiceProvider = Provider( + (ref) => DriftPartnerService( + ref.watch(driftPartnerRepositoryProvider), + ref.watch(partnerApiRepositoryProvider), + ), +); + +final partnerUsersProvider = + NotifierProvider>( + PartnerNotifier.new, +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index d0f8852dc3..ba31ccef2b 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -51,6 +51,7 @@ import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/library/local_albums.page.dart'; import 'package:immich_mobile/pages/library/locked/locked.page.dart'; import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart'; +import 'package:immich_mobile/pages/library/partner/drift_partner.page.dart'; import 'package:immich_mobile/pages/library/partner/partner.page.dart'; import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; @@ -485,6 +486,11 @@ class AppRouter extends RootStackRouter { page: ChangeExperienceRoute.page, guards: [_authGuard, _duplicateGuard], ), + + AutoRoute( + page: DriftPartnerRoute.page, + guards: [_authGuard, _duplicateGuard], + ), AutoRoute( page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard], diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index c9ed8a40a3..0e24f776d8 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -896,7 +896,7 @@ class DriftPartnerDetailRoute extends PageRouteInfo { DriftPartnerDetailRoute({ Key? key, - required UserDto partner, + required PartnerUserDto partner, List? children, }) : super( DriftPartnerDetailRoute.name, @@ -920,7 +920,7 @@ class DriftPartnerDetailRouteArgs { final Key? key; - final UserDto partner; + final PartnerUserDto partner; @override String toString() { @@ -928,6 +928,22 @@ class DriftPartnerDetailRouteArgs { } } +/// generated route for +/// [DriftPartnerPage] +class DriftPartnerRoute extends PageRouteInfo { + const DriftPartnerRoute({List? children}) + : super(DriftPartnerRoute.name, initialChildren: children); + + static const String name = 'DriftPartnerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const DriftPartnerPage(); + }, + ); +} + /// generated route for /// [DriftPlaceDetailPage] class DriftPlaceDetailRoute extends PageRouteInfo { From 826eaedae64d4ef10e27bd2ef3458db2ea88b4d2 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Mon, 21 Jul 2025 23:59:42 +0200 Subject: [PATCH 43/45] feat: play live photos on hover (#19962) --- web/src/lib/components/assets/thumbnail/thumbnail.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 04f6d94e0f..e07e5e99c6 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -343,11 +343,12 @@

{/if} From df318ac641fce158af944cecb1dcf1e1fd9192f4 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Tue, 22 Jul 2025 02:31:45 +0100 Subject: [PATCH 44/45] feat: asset face sync (#20048) * chore: remove thumbnailPath from person sync dto * feat: asset face sync --- .../repositories/sync_stream.repository.dart | 1 - mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../lib/model/sync_asset_face_delete_v1.dart | 99 ++++++++++ .../openapi/lib/model/sync_asset_face_v1.dart | 175 ++++++++++++++++++ .../openapi/lib/model/sync_entity_type.dart | 6 + mobile/openapi/lib/model/sync_person_v1.dart | 10 +- .../openapi/lib/model/sync_request_type.dart | 3 + open-api/immich-openapi-specs.json | 66 ++++++- open-api/typescript-sdk/src/fetch-client.ts | 3 + server/src/database.ts | 2 + server/src/dtos/sync.dto.ts | 22 ++- server/src/enum.ts | 4 + server/src/queries/sync.repository.sql | 36 +++- server/src/repositories/sync.repository.ts | 49 ++++- server/src/schema/functions.ts | 13 ++ server/src/schema/index.ts | 5 + ...04909784-AssetFaceUpdateIdAndAuditTable.ts | 52 ++++++ .../schema/tables/asset-face-audit.table.ts | 17 ++ server/src/schema/tables/asset-face.table.ts | 17 ++ server/src/services/sync.service.ts | 16 ++ server/test/fixtures/face.stub.ts | 16 ++ server/test/medium.factory.ts | 6 + .../medium/specs/sync/sync-asset-face.spec.ts | 92 +++++++++ .../medium/specs/sync/sync-person.spec.ts | 1 - 26 files changed, 699 insertions(+), 20 deletions(-) create mode 100644 mobile/openapi/lib/model/sync_asset_face_delete_v1.dart create mode 100644 mobile/openapi/lib/model/sync_asset_face_v1.dart create mode 100644 server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts create mode 100644 server/src/schema/tables/asset-face-audit.table.ts create mode 100644 server/test/medium/specs/sync/sync-asset-face.spec.ts diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index fb5c7fdb3c..e141c387be 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -523,7 +523,6 @@ class SyncStreamRepository extends DriftDatabaseRepository { ownerId: Value(person.ownerId), name: Value(person.name), faceAssetId: Value(person.faceAssetId), - thumbnailPath: Value(person.thumbnailPath), isFavorite: Value(person.isFavorite), isHidden: Value(person.isHidden), color: Value(person.color), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 28fa63ba84..3e7fa4c2f1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -475,6 +475,8 @@ Class | Method | HTTP request | Description - [SyncAlbumV1](doc//SyncAlbumV1.md) - [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md) - [SyncAssetExifV1](doc//SyncAssetExifV1.md) + - [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md) + - [SyncAssetFaceV1](doc//SyncAssetFaceV1.md) - [SyncAssetV1](doc//SyncAssetV1.md) - [SyncEntityType](doc//SyncEntityType.md) - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index becafa06bf..545955a184 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -257,6 +257,8 @@ part 'model/sync_album_user_v1.dart'; part 'model/sync_album_v1.dart'; part 'model/sync_asset_delete_v1.dart'; part 'model/sync_asset_exif_v1.dart'; +part 'model/sync_asset_face_delete_v1.dart'; +part 'model/sync_asset_face_v1.dart'; part 'model/sync_asset_v1.dart'; part 'model/sync_entity_type.dart'; part 'model/sync_memory_asset_delete_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 603163f00e..55d6f4108b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -570,6 +570,10 @@ class ApiClient { return SyncAssetDeleteV1.fromJson(value); case 'SyncAssetExifV1': return SyncAssetExifV1.fromJson(value); + case 'SyncAssetFaceDeleteV1': + return SyncAssetFaceDeleteV1.fromJson(value); + case 'SyncAssetFaceV1': + return SyncAssetFaceV1.fromJson(value); case 'SyncAssetV1': return SyncAssetV1.fromJson(value); case 'SyncEntityType': diff --git a/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart new file mode 100644 index 0000000000..0992bfdcba --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetFaceDeleteV1 { + /// Returns a new [SyncAssetFaceDeleteV1] instance. + SyncAssetFaceDeleteV1({ + required this.assetFaceId, + }); + + String assetFaceId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetFaceDeleteV1 && + other.assetFaceId == assetFaceId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetFaceId.hashCode); + + @override + String toString() => 'SyncAssetFaceDeleteV1[assetFaceId=$assetFaceId]'; + + Map toJson() { + final json = {}; + json[r'assetFaceId'] = this.assetFaceId; + return json; + } + + /// Returns a new [SyncAssetFaceDeleteV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetFaceDeleteV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetFaceDeleteV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetFaceDeleteV1( + assetFaceId: mapValueOfType(json, r'assetFaceId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetFaceDeleteV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetFaceDeleteV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetFaceDeleteV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetFaceDeleteV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetFaceId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_asset_face_v1.dart b/mobile/openapi/lib/model/sync_asset_face_v1.dart new file mode 100644 index 0000000000..853a8a1514 --- /dev/null +++ b/mobile/openapi/lib/model/sync_asset_face_v1.dart @@ -0,0 +1,175 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SyncAssetFaceV1 { + /// Returns a new [SyncAssetFaceV1] instance. + SyncAssetFaceV1({ + required this.assetId, + required this.boundingBoxX1, + required this.boundingBoxX2, + required this.boundingBoxY1, + required this.boundingBoxY2, + required this.id, + required this.imageHeight, + required this.imageWidth, + required this.personId, + required this.sourceType, + }); + + String assetId; + + num boundingBoxX1; + + num boundingBoxX2; + + num boundingBoxY1; + + num boundingBoxY2; + + String id; + + num imageHeight; + + num imageWidth; + + String? personId; + + String sourceType; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncAssetFaceV1 && + other.assetId == assetId && + other.boundingBoxX1 == boundingBoxX1 && + other.boundingBoxX2 == boundingBoxX2 && + other.boundingBoxY1 == boundingBoxY1 && + other.boundingBoxY2 == boundingBoxY2 && + other.id == id && + other.imageHeight == imageHeight && + other.imageWidth == imageWidth && + other.personId == personId && + other.sourceType == sourceType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (boundingBoxX1.hashCode) + + (boundingBoxX2.hashCode) + + (boundingBoxY1.hashCode) + + (boundingBoxY2.hashCode) + + (id.hashCode) + + (imageHeight.hashCode) + + (imageWidth.hashCode) + + (personId == null ? 0 : personId!.hashCode) + + (sourceType.hashCode); + + @override + String toString() => 'SyncAssetFaceV1[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, personId=$personId, sourceType=$sourceType]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'boundingBoxX1'] = this.boundingBoxX1; + json[r'boundingBoxX2'] = this.boundingBoxX2; + json[r'boundingBoxY1'] = this.boundingBoxY1; + json[r'boundingBoxY2'] = this.boundingBoxY2; + json[r'id'] = this.id; + json[r'imageHeight'] = this.imageHeight; + json[r'imageWidth'] = this.imageWidth; + if (this.personId != null) { + json[r'personId'] = this.personId; + } else { + // json[r'personId'] = null; + } + json[r'sourceType'] = this.sourceType; + return json; + } + + /// Returns a new [SyncAssetFaceV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncAssetFaceV1? fromJson(dynamic value) { + upgradeDto(value, "SyncAssetFaceV1"); + if (value is Map) { + final json = value.cast(); + + return SyncAssetFaceV1( + assetId: mapValueOfType(json, r'assetId')!, + boundingBoxX1: num.parse('${json[r'boundingBoxX1']}'), + boundingBoxX2: num.parse('${json[r'boundingBoxX2']}'), + boundingBoxY1: num.parse('${json[r'boundingBoxY1']}'), + boundingBoxY2: num.parse('${json[r'boundingBoxY2']}'), + id: mapValueOfType(json, r'id')!, + imageHeight: num.parse('${json[r'imageHeight']}'), + imageWidth: num.parse('${json[r'imageWidth']}'), + personId: mapValueOfType(json, r'personId'), + sourceType: mapValueOfType(json, r'sourceType')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetFaceV1.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SyncAssetFaceV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncAssetFaceV1-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SyncAssetFaceV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'boundingBoxX1', + 'boundingBoxX2', + 'boundingBoxY1', + 'boundingBoxY2', + 'id', + 'imageHeight', + 'imageWidth', + 'personId', + 'sourceType', + }; +} + diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 61f94401c7..65ed78105c 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -58,6 +58,8 @@ class SyncEntityType { static const stackDeleteV1 = SyncEntityType._(r'StackDeleteV1'); static const personV1 = SyncEntityType._(r'PersonV1'); static const personDeleteV1 = SyncEntityType._(r'PersonDeleteV1'); + static const assetFaceV1 = SyncEntityType._(r'AssetFaceV1'); + static const assetFaceDeleteV1 = SyncEntityType._(r'AssetFaceDeleteV1'); static const userMetadataV1 = SyncEntityType._(r'UserMetadataV1'); static const userMetadataDeleteV1 = SyncEntityType._(r'UserMetadataDeleteV1'); static const syncAckV1 = SyncEntityType._(r'SyncAckV1'); @@ -100,6 +102,8 @@ class SyncEntityType { stackDeleteV1, personV1, personDeleteV1, + assetFaceV1, + assetFaceDeleteV1, userMetadataV1, userMetadataDeleteV1, syncAckV1, @@ -177,6 +181,8 @@ class SyncEntityTypeTypeTransformer { case r'StackDeleteV1': return SyncEntityType.stackDeleteV1; case r'PersonV1': return SyncEntityType.personV1; case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; + case r'AssetFaceV1': return SyncEntityType.assetFaceV1; + case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1; case r'UserMetadataV1': return SyncEntityType.userMetadataV1; case r'UserMetadataDeleteV1': return SyncEntityType.userMetadataDeleteV1; case r'SyncAckV1': return SyncEntityType.syncAckV1; diff --git a/mobile/openapi/lib/model/sync_person_v1.dart b/mobile/openapi/lib/model/sync_person_v1.dart index e86c22f64b..6749beb3e1 100644 --- a/mobile/openapi/lib/model/sync_person_v1.dart +++ b/mobile/openapi/lib/model/sync_person_v1.dart @@ -22,7 +22,6 @@ class SyncPersonV1 { required this.isHidden, required this.name, required this.ownerId, - required this.thumbnailPath, required this.updatedAt, }); @@ -44,8 +43,6 @@ class SyncPersonV1 { String ownerId; - String thumbnailPath; - DateTime updatedAt; @override @@ -59,7 +56,6 @@ class SyncPersonV1 { other.isHidden == isHidden && other.name == name && other.ownerId == ownerId && - other.thumbnailPath == thumbnailPath && other.updatedAt == updatedAt; @override @@ -74,11 +70,10 @@ class SyncPersonV1 { (isHidden.hashCode) + (name.hashCode) + (ownerId.hashCode) + - (thumbnailPath.hashCode) + (updatedAt.hashCode); @override - String toString() => 'SyncPersonV1[birthDate=$birthDate, color=$color, createdAt=$createdAt, faceAssetId=$faceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, ownerId=$ownerId, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'SyncPersonV1[birthDate=$birthDate, color=$color, createdAt=$createdAt, faceAssetId=$faceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, ownerId=$ownerId, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -103,7 +98,6 @@ class SyncPersonV1 { json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'ownerId'] = this.ownerId; - json[r'thumbnailPath'] = this.thumbnailPath; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -126,7 +120,6 @@ class SyncPersonV1 { isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, ownerId: mapValueOfType(json, r'ownerId')!, - thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } @@ -184,7 +177,6 @@ class SyncPersonV1 { 'isHidden', 'name', 'ownerId', - 'thumbnailPath', 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 75ce852f9f..800b3f4485 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -39,6 +39,7 @@ class SyncRequestType { static const stacksV1 = SyncRequestType._(r'StacksV1'); static const usersV1 = SyncRequestType._(r'UsersV1'); static const peopleV1 = SyncRequestType._(r'PeopleV1'); + static const assetFacesV1 = SyncRequestType._(r'AssetFacesV1'); static const userMetadataV1 = SyncRequestType._(r'UserMetadataV1'); /// List of all possible values in this [enum][SyncRequestType]. @@ -59,6 +60,7 @@ class SyncRequestType { stacksV1, usersV1, peopleV1, + assetFacesV1, userMetadataV1, ]; @@ -114,6 +116,7 @@ class SyncRequestTypeTypeTransformer { case r'StacksV1': return SyncRequestType.stacksV1; case r'UsersV1': return SyncRequestType.usersV1; case r'PeopleV1': return SyncRequestType.peopleV1; + case r'AssetFacesV1': return SyncRequestType.assetFacesV1; case r'UserMetadataV1': return SyncRequestType.userMetadataV1; default: if (!allowNull) { diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 71329c3f75..2f41318d6d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13788,6 +13788,65 @@ ], "type": "object" }, + "SyncAssetFaceDeleteV1": { + "properties": { + "assetFaceId": { + "type": "string" + } + }, + "required": [ + "assetFaceId" + ], + "type": "object" + }, + "SyncAssetFaceV1": { + "properties": { + "assetId": { + "type": "string" + }, + "boundingBoxX1": { + "type": "number" + }, + "boundingBoxX2": { + "type": "number" + }, + "boundingBoxY1": { + "type": "number" + }, + "boundingBoxY2": { + "type": "number" + }, + "id": { + "type": "string" + }, + "imageHeight": { + "type": "number" + }, + "imageWidth": { + "type": "number" + }, + "personId": { + "nullable": true, + "type": "string" + }, + "sourceType": { + "type": "string" + } + }, + "required": [ + "assetId", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "id", + "imageHeight", + "imageWidth", + "personId", + "sourceType" + ], + "type": "object" + }, "SyncAssetV1": { "properties": { "checksum": { @@ -13912,6 +13971,8 @@ "StackDeleteV1", "PersonV1", "PersonDeleteV1", + "AssetFaceV1", + "AssetFaceDeleteV1", "UserMetadataV1", "UserMetadataDeleteV1", "SyncAckV1", @@ -14109,9 +14170,6 @@ "ownerId": { "type": "string" }, - "thumbnailPath": { - "type": "string" - }, "updatedAt": { "format": "date-time", "type": "string" @@ -14127,7 +14185,6 @@ "isHidden", "name", "ownerId", - "thumbnailPath", "updatedAt" ], "type": "object" @@ -14150,6 +14207,7 @@ "StacksV1", "UsersV1", "PeopleV1", + "AssetFacesV1", "UserMetadataV1" ], "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f60fa6dfe8..d5f7fde52a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4125,6 +4125,8 @@ export enum SyncEntityType { StackDeleteV1 = "StackDeleteV1", PersonV1 = "PersonV1", PersonDeleteV1 = "PersonDeleteV1", + AssetFaceV1 = "AssetFaceV1", + AssetFaceDeleteV1 = "AssetFaceDeleteV1", UserMetadataV1 = "UserMetadataV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1", SyncAckV1 = "SyncAckV1", @@ -4147,6 +4149,7 @@ export enum SyncRequestType { StacksV1 = "StacksV1", UsersV1 = "UsersV1", PeopleV1 = "PeopleV1", + AssetFacesV1 = "AssetFacesV1", UserMetadataV1 = "UserMetadataV1" } export enum TranscodeHWAccel { diff --git a/server/src/database.ts b/server/src/database.ts index d42b2618a4..dc99fc5b31 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -272,6 +272,8 @@ export type AssetFace = { personId: string | null; sourceType: SourceType; person?: Person | null; + updatedAt: Date; + updateId: string; }; const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 9725539e3d..e0c9c059c4 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -245,7 +245,6 @@ export class SyncPersonV1 { ownerId!: string; name!: string; birthDate!: Date | null; - thumbnailPath!: string; isHidden!: boolean; isFavorite!: boolean; color!: string | null; @@ -257,6 +256,25 @@ export class SyncPersonDeleteV1 { personId!: string; } +@ExtraModel() +export class SyncAssetFaceV1 { + id!: string; + assetId!: string; + personId!: string | null; + imageWidth!: number; + imageHeight!: number; + boundingBoxX1!: number; + boundingBoxY1!: number; + boundingBoxX2!: number; + boundingBoxY2!: number; + sourceType!: string; +} + +@ExtraModel() +export class SyncAssetFaceDeleteV1 { + assetFaceId!: string; +} + @ExtraModel() export class SyncUserMetadataV1 { userId!: string; @@ -312,6 +330,8 @@ export type SyncItem = { [SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; + [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; + [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index e41a790999..f2eae615ab 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -568,6 +568,7 @@ export enum SyncRequestType { StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', + AssetFacesV1 = 'AssetFacesV1', UserMetadataV1 = 'UserMetadataV1', } @@ -619,6 +620,9 @@ export enum SyncEntityType { PersonV1 = 'PersonV1', PersonDeleteV1 = 'PersonDeleteV1', + AssetFaceV1 = 'AssetFaceV1', + AssetFaceDeleteV1 = 'AssetFaceDeleteV1', + UserMetadataV1 = 'UserMetadataV1', UserMetadataDeleteV1 = 'UserMetadataDeleteV1', diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 4782eedf1d..7502b79f57 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -409,6 +409,41 @@ where order by "updateId" asc +-- SyncRepository.assetFace.getDeletes +select + "asset_face_audit"."id", + "assetFaceId" +from + "asset_face_audit" + left join "asset" on "asset"."id" = "asset_face_audit"."assetId" +where + "asset"."ownerId" = $1 + and "asset_face_audit"."deletedAt" < now() - interval '1 millisecond' +order by + "asset_face_audit"."id" asc + +-- SyncRepository.assetFace.getUpserts +select + "asset_face"."id", + "assetId", + "personId", + "imageWidth", + "imageHeight", + "boundingBoxX1", + "boundingBoxY1", + "boundingBoxX2", + "boundingBoxY2", + "sourceType", + "asset_face"."updateId" +from + "asset_face" + left join "asset" on "asset"."id" = "asset_face"."assetId" +where + "asset_face"."updatedAt" < now() - interval '1 millisecond' + and "asset"."ownerId" = $1 +order by + "asset_face"."updateId" asc + -- SyncRepository.memory.getDeletes select "id", @@ -779,7 +814,6 @@ select "ownerId", "name", "birthDate", - "thumbnailPath", "isHidden", "isFavorite", "color", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 34c450d52d..dba52d25a0 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -17,7 +17,8 @@ type AuditTables = | 'memory_asset_audit' | 'stack_audit' | 'person_audit' - | 'user_metadata_audit'; + | 'user_metadata_audit' + | 'asset_face_audit'; type UpsertTables = | 'user' | 'partner' @@ -29,7 +30,8 @@ type UpsertTables = | 'memory_asset' | 'stack' | 'person' - | 'user_metadata'; + | 'user_metadata' + | 'asset_face'; @Injectable() export class SyncRepository { @@ -40,6 +42,7 @@ export class SyncRepository { albumUser: AlbumUserSync; asset: AssetSync; assetExif: AssetExifSync; + assetFace: AssetFaceSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; partner: PartnerSync; @@ -59,6 +62,7 @@ export class SyncRepository { this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); + this.assetFace = new AssetFaceSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); this.partner = new PartnerSync(this.db); @@ -385,7 +389,6 @@ class PersonSync extends BaseSync { 'ownerId', 'name', 'birthDate', - 'thumbnailPath', 'isHidden', 'isFavorite', 'color', @@ -398,6 +401,46 @@ class PersonSync extends BaseSync { } } +class AssetFaceSync extends BaseSync { + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_face_audit') + .select(['asset_face_audit.id', 'assetFaceId']) + .orderBy('asset_face_audit.id', 'asc') + .leftJoin('asset', 'asset.id', 'asset_face_audit.assetId') + .where('asset.ownerId', '=', userId) + .where('asset_face_audit.deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('asset_face_audit.id', '>', ack!.updateId)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_face') + .select([ + 'asset_face.id', + 'assetId', + 'personId', + 'imageWidth', + 'imageHeight', + 'boundingBoxX1', + 'boundingBoxY1', + 'boundingBoxX2', + 'boundingBoxY2', + 'sourceType', + 'asset_face.updateId', + ]) + .where('asset_face.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('asset_face.updateId', '>', ack!.updateId)) + .orderBy('asset_face.updateId', 'asc') + .leftJoin('asset', 'asset.id', 'asset_face.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } +} + class AssetExifSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getUpserts(userId: string, ack?: SyncAck) { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 5577169227..786e7a1ffa 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -229,3 +229,16 @@ export const user_metadata_audit = registerFunction({ RETURN NULL; END`, }); + +export const asset_face_audit = registerFunction({ + name: 'asset_face_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_face_audit ("assetFaceId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index ba25a65d4d..8982437b34 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -4,6 +4,7 @@ import { album_user_after_insert, album_user_delete_audit, asset_delete_audit, + asset_face_audit, f_concat_ws, f_unaccent, immich_uuid_v7, @@ -27,6 +28,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; @@ -78,6 +80,7 @@ export class ImmichDatabase { ApiKeyTable, AssetAuditTable, AssetFaceTable, + AssetFaceAuditTable, AssetJobStatusTable, AssetTable, AssetFileTable, @@ -132,6 +135,7 @@ export class ImmichDatabase { stack_delete_audit, person_delete_audit, user_metadata_audit, + asset_face_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -158,6 +162,7 @@ export interface DB { asset: AssetTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; + asset_face_audit: AssetFaceAuditTable; asset_file: AssetFileTable; asset_job_status: AssetJobStatusTable; asset_audit: AssetAuditTable; diff --git a/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts b/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts new file mode 100644 index 0000000000..1f4072e34e --- /dev/null +++ b/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts @@ -0,0 +1,52 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_face_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_face_audit ("assetFaceId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_face_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "assetFaceId" uuid NOT NULL, + "assetId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_face_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_face_audit_assetFaceId_idx" ON "asset_face_audit" ("assetFaceId");`.execute(db); + await sql`CREATE INDEX "asset_face_audit_assetId_idx" ON "asset_face_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_face_audit_deletedAt_idx" ON "asset_face_audit" ("deletedAt");`.execute(db); + await sql`ALTER TABLE "asset_face" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "asset_face" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_face_audit" + AFTER DELETE ON "asset_face" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_face_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_face_updatedAt" + BEFORE UPDATE ON "asset_face" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_face_audit', '{"type":"function","name":"asset_face_audit","sql":"CREATE OR REPLACE FUNCTION asset_face_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_face_audit (\\"assetFaceId\\", \\"assetId\\")\\n SELECT \\"id\\", \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_face_audit', '{"type":"trigger","name":"asset_face_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_face_audit\\"\\n AFTER DELETE ON \\"asset_face\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_face_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_face_updatedAt', '{"type":"trigger","name":"asset_face_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_face_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_face\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_face_audit" ON "asset_face";`.execute(db); + await sql`DROP TRIGGER "asset_face_updatedAt" ON "asset_face";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "asset_face_audit";`.execute(db); + await sql`DROP FUNCTION asset_face_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_face_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_face_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_face_updatedAt';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face-audit.table.ts b/server/src/schema/tables/asset-face-audit.table.ts new file mode 100644 index 0000000000..4f03c22aa0 --- /dev/null +++ b/server/src/schema/tables/asset-face-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('asset_face_audit') +export class AssetFaceAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + assetFaceId!: string; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 6e45a3a64d..5041d945e2 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,8 +1,11 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { SourceType } from 'src/enum'; import { asset_face_source_type } from 'src/schema/enums'; +import { asset_face_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { + AfterDeleteTrigger, Column, DeleteDateColumn, ForeignKeyColumn, @@ -11,9 +14,17 @@ import { PrimaryGeneratedColumn, Table, Timestamp, + UpdateDateColumn, } from 'src/sql-tools'; @Table({ name: 'asset_face' }) +@UpdatedAtTrigger('asset_face_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_face_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) // schemaFromDatabase does not preserve column order @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) @Index({ columns: ['personId', 'assetId'] }) @@ -61,4 +72,10 @@ export class AssetFaceTable { @DeleteDateColumn() deletedAt!: Timestamp | null; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn() + updateId!: Generated; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 4463ab0d76..fb582ab038 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -70,6 +70,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, SyncRequestType.PeopleV1, + SyncRequestType.AssetFacesV1, SyncRequestType.UserMetadataV1, ]; @@ -156,6 +157,7 @@ export class SyncService extends BaseService { [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), + [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(response, checkpointMap, auth), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(response, checkpointMap, auth), }; @@ -606,6 +608,20 @@ export class SyncService extends BaseService { } } + private async syncAssetFacesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.AssetFaceDeleteV1; + const deletes = this.syncRepository.assetFace.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFaceV1; + const upserts = this.syncRepository.assetFace.getUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncUserMetadataV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.UserMetadataDeleteV1; const deletes = this.syncRepository.userMetadata.getDeletes(auth.user.id, checkpointMap[deleteType]); diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index beecf7c69e..f655a3944e 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -23,6 +23,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, deletedAt: new Date(), + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), primaryFace1: Object.freeze({ id: 'assetFaceId2', @@ -39,6 +41,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), mergeFace1: Object.freeze({ id: 'assetFaceId3', @@ -55,6 +59,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -71,6 +77,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -87,6 +95,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -102,6 +112,8 @@ export const faceStub = { imageWidth: 400, sourceType: SourceType.Exif, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -117,6 +129,8 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.Exif, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), withBirthDate: Object.freeze({ id: 'assetFaceId10', @@ -132,5 +146,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MachineLearning, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), }; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 4d13264fa2..d6038b6b84 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -154,6 +154,12 @@ export class MediumTestContext { return { asset, result }; } + async newAssetFace(dto: Partial> & { assetId: string }) { + const assetFace = mediumFactory.assetFaceInsert(dto); + const result = await this.get(PersonRepository).createAssetFace(assetFace); + return { assetFace, result }; + } + async newMemory(dto: Partial> = {}) { const memory = mediumFactory.memoryInsert(dto); const result = await this.get(MemoryRepository).create(memory, new Set()); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts new file mode 100644 index 0000000000..68d3007c52 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -0,0 +1,92 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AssetFaceV1, () => { + it('should detect and sync the first asset face', async () => { + const { auth, ctx } = await setup(); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + }), + type: 'AssetFaceV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).resolves.toEqual([]); + }); + + it('should detect and sync a deleted asset face', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).resolves.toEqual([]); + }); + + it('should not sync an asset face or asset face delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).toHaveLength(0); + + await personRepo.deleteAssetFace(assetFace.id); + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 807e41894c..fbf401e377 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -31,7 +31,6 @@ describe(SyncEntityType.PersonV1, () => { data: expect.objectContaining({ id: person.id, name: person.name, - thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, birthDate: person.birthDate, faceAssetId: person.faceAssetId, From 02c423b32672775c3786a69f46cb0b03bcccfd28 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Jul 2025 22:44:39 -0500 Subject: [PATCH 45/45] chore: graceful(not) disposal(be gong) Isar (#20062) --- mobile/lib/utils/isolate.dart | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mobile/lib/utils/isolate.dart b/mobile/lib/utils/isolate.dart index 2263bf3d76..3c2aeed756 100644 --- a/mobile/lib/utils/isolate.dart +++ b/mobile/lib/utils/isolate.dart @@ -63,7 +63,17 @@ Cancelable runInIsolateGentle({ try { await LogService.I.flushBuffer(); await ref.read(driftProvider).close(); - await ref.read(isarProvider).close(); + + // Close Isar safely + try { + final isar = ref.read(isarProvider); + if (isar.isOpen) { + await isar.close(); + } + } catch (e) { + debugPrint("Error closing Isar: $e"); + } + ref.dispose(); } catch (error) { debugPrint("Error closing resources in isolate: $error");