diff --git a/server/Dockerfile b/server/Dockerfile index be453bba8..7d721d3f7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,13 +10,17 @@ RUN npm ci && \ rm -rf node_modules/@img/sharp-libvips* && \ rm -rf node_modules/@img/sharp-linuxmusl-x64 COPY server . + +WORKDIR /usr/src/app/server +RUN npm run prisma:generate + +WORKDIR /usr/src/app ENV PATH="${PATH}:/usr/src/app/bin" \ NODE_ENV=development \ NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_VISIBLE_DEVICES=all ENTRYPOINT ["tini", "--", "/bin/sh"] - FROM dev AS prod RUN npm run build diff --git a/server/package-lock.json b/server/package-lock.json index d36229b14..917265a4d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -25,6 +25,7 @@ "@opentelemetry/auto-instrumentations-node": "^0.43.0", "@opentelemetry/exporter-prometheus": "^0.49.0", "@opentelemetry/sdk-node": "^0.49.0", + "@prisma/client": "^5.11.0", "@socket.io/postgres-adapter": "^0.3.1", "@types/picomatch": "^2.3.3", "archiver": "^7.0.0", @@ -96,6 +97,7 @@ "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", + "prisma": "^5.11.0", "rimraf": "^5.0.1", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", @@ -4061,6 +4063,68 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" }, + "node_modules/@prisma/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.11.0.tgz", + "integrity": "sha512-SWshvS5FDXvgJKM/a0y9nDC1rqd7KG0Q6ZVzd+U7ZXK5soe73DJxJJgbNBt2GNXOa+ysWB4suTpdK5zfFPhwiw==", + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.11.0.tgz", + "integrity": "sha512-N6yYr3AbQqaiUg+OgjkdPp3KPW1vMTAgtKX6+BiB/qB2i1TjLYCrweKcUjzOoRM5BriA4idrkTej9A9QqTfl3A==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.11.0.tgz", + "integrity": "sha512-gbrpQoBTYWXDRqD+iTYMirDlF9MMlQdxskQXbhARhG6A/uFQjB7DZMYocMQLoiZXO/IskfDOZpPoZE8TBQKtEw==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/fetch-engine": "5.11.0", + "@prisma/get-platform": "5.11.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102.tgz", + "integrity": "sha512-WXCuyoymvrS4zLz4wQagSsc3/nE6CHy8znyiMv8RKazKymOMd5o9FP5RGwGHAtgoxd+aB/BWqxuP/Ckfu7/3MA==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.11.0.tgz", + "integrity": "sha512-994viazmHTJ1ymzvWugXod7dZ42T2ROeFuH6zHPcUfp/69+6cl5r9u3NFb6bW8lLdNjwLYEVPeu3hWzxpZeC0w==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/get-platform": "5.11.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.11.0.tgz", + "integrity": "sha512-rxtHpMLxNTHxqWuGOLzR2QOyQi79rK1u1XYAVLZxDGTLz/A+uoDnjz9veBFlicrpWjwuieM4N6jcnjj/DDoidw==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.11.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -11515,6 +11579,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prisma": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.11.0.tgz", + "integrity": "sha512-KCLiug2cs0Je7kGkQBN9jDWoZ90ogE/kvZTUTgz2h94FEo8pczCkPH7fPNXkD1sGU7Yh65risGGD1HQ5DF3r3g==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.11.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -17153,6 +17233,56 @@ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" }, + "@prisma/client": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.11.0.tgz", + "integrity": "sha512-SWshvS5FDXvgJKM/a0y9nDC1rqd7KG0Q6ZVzd+U7ZXK5soe73DJxJJgbNBt2GNXOa+ysWB4suTpdK5zfFPhwiw==", + "requires": {} + }, + "@prisma/debug": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.11.0.tgz", + "integrity": "sha512-N6yYr3AbQqaiUg+OgjkdPp3KPW1vMTAgtKX6+BiB/qB2i1TjLYCrweKcUjzOoRM5BriA4idrkTej9A9QqTfl3A==", + "devOptional": true + }, + "@prisma/engines": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.11.0.tgz", + "integrity": "sha512-gbrpQoBTYWXDRqD+iTYMirDlF9MMlQdxskQXbhARhG6A/uFQjB7DZMYocMQLoiZXO/IskfDOZpPoZE8TBQKtEw==", + "devOptional": true, + "requires": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/fetch-engine": "5.11.0", + "@prisma/get-platform": "5.11.0" + } + }, + "@prisma/engines-version": { + "version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102.tgz", + "integrity": "sha512-WXCuyoymvrS4zLz4wQagSsc3/nE6CHy8znyiMv8RKazKymOMd5o9FP5RGwGHAtgoxd+aB/BWqxuP/Ckfu7/3MA==", + "devOptional": true + }, + "@prisma/fetch-engine": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.11.0.tgz", + "integrity": "sha512-994viazmHTJ1ymzvWugXod7dZ42T2ROeFuH6zHPcUfp/69+6cl5r9u3NFb6bW8lLdNjwLYEVPeu3hWzxpZeC0w==", + "devOptional": true, + "requires": { + "@prisma/debug": "5.11.0", + "@prisma/engines-version": "5.11.0-15.efd2449663b3d73d637ea1fd226bafbcf45b3102", + "@prisma/get-platform": "5.11.0" + } + }, + "@prisma/get-platform": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.11.0.tgz", + "integrity": "sha512-rxtHpMLxNTHxqWuGOLzR2QOyQi79rK1u1XYAVLZxDGTLz/A+uoDnjz9veBFlicrpWjwuieM4N6jcnjj/DDoidw==", + "devOptional": true, + "requires": { + "@prisma/debug": "5.11.0" + } + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -22910,6 +23040,15 @@ } } }, + "prisma": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.11.0.tgz", + "integrity": "sha512-KCLiug2cs0Je7kGkQBN9jDWoZ90ogE/kvZTUTgz2h94FEo8pczCkPH7fPNXkD1sGU7Yh65risGGD1HQ5DF3r3g==", + "devOptional": true, + "requires": { + "@prisma/engines": "5.11.0" + } + }, "process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", diff --git a/server/package.json b/server/package.json index ead525a6e..747b7699e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,7 @@ { "name": "immich", "version": "1.99.0", + "version": "1.99.0", "description": "", "author": "", "private": true, @@ -29,8 +30,13 @@ "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js", "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js", "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'", + "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/database.config.js", + "typeorm:migrations:run": "typeorm migration:run -d ./dist/database.config.js", + "typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js", + "typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'", "typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run", - "sql:generate": "node ./dist/utils/sql.js" + "sql:generate": "node ./dist/utils/sql.js", + "prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma" }, "dependencies": { "@babel/runtime": "^7.22.11", @@ -49,6 +55,7 @@ "@opentelemetry/auto-instrumentations-node": "^0.43.0", "@opentelemetry/exporter-prometheus": "^0.49.0", "@opentelemetry/sdk-node": "^0.49.0", + "@prisma/client": "^5.11.0", "@socket.io/postgres-adapter": "^0.3.1", "@types/picomatch": "^2.3.3", "archiver": "^7.0.0", @@ -120,6 +127,7 @@ "mock-fs": "^5.2.0", "prettier": "^3.0.2", "prettier-plugin-organize-imports": "^3.2.3", + "prisma": "^5.11.0", "rimraf": "^5.0.1", "source-map-support": "^0.5.21", "sql-formatter": "^15.0.0", diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 47c13041f..13cb4e049 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; @@ -78,22 +79,6 @@ export interface TimeBucketItem { count: number; } -export type AssetCreate = Pick< - AssetEntity, - | 'deviceAssetId' - | 'ownerId' - | 'libraryId' - | 'deviceId' - | 'type' - | 'originalPath' - | 'fileCreatedAt' - | 'localDateTime' - | 'fileModifiedAt' - | 'checksum' - | 'originalFileName' -> & - Partial; - export type AssetWithoutRelations = Omit< AssetEntity, | 'livePhotoVideo' @@ -109,6 +94,22 @@ export type AssetWithoutRelations = Omit< | 'tags' >; +export type AssetCreate = Pick< + AssetEntity, + | 'deviceAssetId' + | 'ownerId' + | 'libraryId' + | 'deviceId' + | 'type' + | 'originalPath' + | 'fileCreatedAt' + | 'localDateTime' + | 'fileModifiedAt' + | 'checksum' + | 'originalFileName' +> & + Partial; + export type AssetUpdateOptions = Pick & Partial; export type AssetUpdateAllOptions = Omit, 'id'>; @@ -139,18 +140,13 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { create(asset: AssetCreate): Promise; - getByDate(ownerId: string, date: Date): Promise; - getByIds( - ids: string[], - relations?: FindOptionsRelations, - select?: FindOptionsSelect, - ): Promise; + getByIds(ids: string[], relations?: Prisma.AssetsInclude): Promise; getByIdsWithAllRelations(ids: string[]): Promise; getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById(id: string, relations?: FindOptionsRelations): Promise; + getById(id: string, relations?: Prisma.AssetsInclude): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; getRandom(userId: string, count: number): Promise; @@ -162,7 +158,7 @@ export interface IAssetRepository { getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; updateAll(ids: string[], options: Partial): Promise; - update(asset: AssetUpdateOptions): Promise; + update(asset: AssetUpdateOptions): Promise; remove(asset: AssetEntity): Promise; softDeleteAll(ids: string[]): Promise; restoreAll(ids: string[]): Promise; diff --git a/server/src/main.ts b/server/src/main.ts index 3a9303868..44622c131 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -71,6 +71,18 @@ async function bootstrapApi() { logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `); } +declare global { + interface BigInt { + toJSON(): number | string; + } +} + +const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER); + +BigInt.prototype.toJSON = function () { + return this.valueOf() > MAX_SAFE_INTEGER ? this.toString() : Number(this.valueOf()); +}; + const immichApp = process.argv[2] || process.env.IMMICH_APP; if (process.argv[2] === immichApp) { diff --git a/server/src/prisma/find-non-deleted.ts b/server/src/prisma/find-non-deleted.ts new file mode 100644 index 000000000..db515828a --- /dev/null +++ b/server/src/prisma/find-non-deleted.ts @@ -0,0 +1,28 @@ +import { Prisma } from '@prisma/client'; + +const excludeDeleted = ({ args, query }: { args: any; query: any }) => { + if (args.where === undefined) { + args.where = { deletedAt: null }; + } else if (args.where.deletedAt === undefined) { + args.where.deletedAt = null; + } + + return query(args); +}; + +const findNonDeleted = { + findFirst: excludeDeleted, + findFirstOrThrow: excludeDeleted, + findMany: excludeDeleted, + findUnique: excludeDeleted, + findUniqueOrThrow: excludeDeleted, +}; + +export const findNonDeletedExtension = Prisma.defineExtension({ + query: { + albums: findNonDeleted, + assets: findNonDeleted, + libraries: findNonDeleted, + users: findNonDeleted, + }, +}); diff --git a/server/src/prisma/metrics.ts b/server/src/prisma/metrics.ts new file mode 100644 index 000000000..84741d61f --- /dev/null +++ b/server/src/prisma/metrics.ts @@ -0,0 +1,17 @@ +import { Prisma } from '@prisma/client'; +import util from 'node:util'; + +export const metricsExtension = Prisma.defineExtension({ + query: { + $allModels: { + async $allOperations({ operation, model, args, query }) { + const start = performance.now(); + const result = await query(args); + const end = performance.now(); + const time = end - start; + console.log(util.inspect({ model, operation, args, time }, { showHidden: false, depth: null, colors: true })); + return result; + }, + }, + }, +}); diff --git a/server/src/prisma/schema.prisma b/server/src/prisma/schema.prisma new file mode 100644 index 000000000..41f1a6ae7 --- /dev/null +++ b/server/src/prisma/schema.prisma @@ -0,0 +1,463 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions", "relationJoins"] +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") + extensions = [cube, earthdistance, pg_trgm, unaccent, uuid_ossp(map: "uuid-ossp", schema: "public"), vectors(map: "vectors", schema: "vectors")] +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Activity { + id String @id(map: "PK_24625a1d6b1b089c8ae206fe467") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + albumId String @db.Uuid + userId String @db.Uuid + assetId String? @db.Uuid + comment String? + isLiked Boolean @default(false) + albums Albums @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "FK_1af8519996fbfb3684b58df280b") + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_3571467bcbe021f66e2bdce96ea") + assets Assets? @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_8091ea76b12338cb4428d33d782") + + @@map(name: "activity") +} + +/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments +model Albums { + id String @id(map: "PK_7f71c7b5bc7c87b8f94c9a93a00") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + ownerId String @db.Uuid + albumName String @default("Untitled Album") @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + albumThumbnailAssetId String? @db.Uuid + updatedAt DateTime @default(now()) @db.Timestamptz(6) + description String @default("") + deletedAt DateTime? @db.Timestamptz(6) + isActivityEnabled Boolean @default(true) + order String @default("desc") @db.VarChar + activity Activity[] + assets Assets? @relation(fields: [albumThumbnailAssetId], references: [id], map: "FK_05895aa505a670300d4816debce") + users Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_b22c53f35ef20c28c21637c85f4") + albums_assets_assets AlbumsAssetsAssets[] + albums_shared_users_users AlbumsSharedUsersUsers[] + shared_links SharedLinks[] + + @@map(name: "albums") +} + +model AlbumsAssetsAssets { + albumsId String @db.Uuid + assetsId String @db.Uuid + assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_4bd1303d199f4e72ccdf998c621") + albums Albums @relation(fields: [albumsId], references: [id], onDelete: Cascade, map: "FK_e590fa396c6898fcd4a50e40927") + + @@id([albumsId, assetsId], map: "PK_c67bc36fa845fb7b18e0e398180") + @@index([assetsId], map: "IDX_4bd1303d199f4e72ccdf998c62") + @@index([albumsId], map: "IDX_e590fa396c6898fcd4a50e4092") + @@map(name: "albums_assets_assets") +} + +model AlbumsSharedUsersUsers { + albumsId String @db.Uuid + usersId String @db.Uuid + albums Albums @relation(fields: [albumsId], references: [id], onDelete: Cascade, map: "FK_427c350ad49bd3935a50baab737") + users Users @relation(fields: [usersId], references: [id], onDelete: Cascade, map: "FK_f48513bf9bccefd6ff3ad30bd06") + + @@id([albumsId, usersId], map: "PK_7df55657e0b2e8b626330a0ebc8") + @@index([albumsId], map: "IDX_427c350ad49bd3935a50baab73") + @@index([usersId], map: "IDX_f48513bf9bccefd6ff3ad30bd0") + @@map(name: "albums_shared_users_users") +} + +model ApiKeys { + name String @db.VarChar + key String @db.VarChar + userId String @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + id String @id(map: "PK_5c8a79801b44bd27b79228e1dad") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_6c2e267ae764a9413b863a29342") + + @@map(name: "api_keys") +} + +model AssetFaces { + assetId String @db.Uuid + personId String? @db.Uuid + embedding Unsupported("vector") + imageWidth Int @default(0) + imageHeight Int @default(0) + boundingBoxX1 Int @default(0) + boundingBoxY1 Int @default(0) + boundingBoxX2 Int @default(0) + boundingBoxY2 Int @default(0) + id String @id(map: "PK_6df76ab2eb6f5b57b7c2f1fc684") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_02a43fd0b3c50fb6d7f0cb7282c") + person Person? @relation("asset_faces_personIdToperson", fields: [personId], references: [id], map: "FK_95ad7106dd7b484275443f580f9") + person_person_faceAssetIdToasset_faces Person[] @relation("person_faceAssetIdToasset_faces") + + @@index([assetId, personId], map: "IDX_asset_faces_assetId_personId") + @@index([assetId], map: "IDX_asset_faces_on_assetId") + @@index([personId], map: "IDX_asset_faces_personId") + @@index([personId, assetId], map: "IDX_bf339a24070dac7e71304ec530") + @@index([embedding], map: "face_index") + @@map(name: "asset_faces") +} + +model AssetJobStatus { + assetId String @id(map: "PK_420bec36fc02813bddf5c8b73d4") @db.Uuid + facesRecognizedAt DateTime? @db.Timestamptz(6) + metadataExtractedAt DateTime? @db.Timestamptz(6) + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_420bec36fc02813bddf5c8b73d4") + + @@map(name: "asset_job_status") +} + +model AssetStack { + id String @id(map: "PK_74a27e7fcbd5852463d0af3034b") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + primaryAssetId String @unique(map: "REL_91704e101438fd0653f582426d") @db.Uuid + primaryAsset Assets @relation("asset_stack_primaryAssetIdToassets", fields: [primaryAssetId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_91704e101438fd0653f582426dc") + assets Assets[] @relation("assets_stackIdToasset_stack") + + @@map(name: "asset_stack") +} + +/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. +model Assets { + id String @id(map: "PK_da96729a8b113377cfb6a62439c") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + deviceAssetId String @db.VarChar + ownerId String @db.Uuid + deviceId String @db.VarChar + type String @db.VarChar + originalPath String @db.VarChar + resizePath String? @db.VarChar + fileCreatedAt DateTime @db.Timestamptz(6) + fileModifiedAt DateTime @db.Timestamptz(6) + isFavorite Boolean @default(false) + duration String? @db.VarChar + webpPath String? @default("") @db.VarChar + encodedVideoPath String? @default("") @db.VarChar + checksum Bytes + isVisible Boolean @default(true) + livePhotoVideoId String? @unique(map: "UQ_16294b83fa8c0149719a1f631ef") @db.Uuid + updatedAt DateTime @default(now()) @db.Timestamptz(6) + createdAt DateTime @default(now()) @db.Timestamptz(6) + isArchived Boolean @default(false) + originalFileName String @db.VarChar + sidecarPath String? @db.VarChar + isReadOnly Boolean @default(false) + thumbhash Bytes? + isOffline Boolean @default(false) + libraryId String @db.Uuid + isExternal Boolean @default(false) + deletedAt DateTime? @db.Timestamptz(6) + localDateTime DateTime @db.Timestamptz(6) + stackId String? @db.Uuid + truncatedDate DateTime @default(dbgenerated("date_trunc('day', \"localDateTime\" at time zone 'UTC') at time zone 'UTC'")) @db.Timestamptz(6) + activity Activity[] + albums Albums[] + albumsAssetsAssets AlbumsAssetsAssets[] + faces AssetFaces[] + assetJobStatus AssetJobStatus? + assetStackAssetStackPrimaryAssetIdToAssets AssetStack? @relation("asset_stack_primaryAssetIdToassets") + livePhotoVideo Assets? @relation("assetsToassets", fields: [livePhotoVideoId], references: [id], map: "FK_16294b83fa8c0149719a1f631ef") + otherAssets Assets? @relation("assetsToassets") + owner Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_2c5ac0d6fb58b238fd2068de67d") + library Libraries @relation(fields: [libraryId], references: [id], onDelete: Cascade, map: "FK_9977c3c1de01c3d848039a6b90c") + stack AssetStack? @relation("assets_stackIdToasset_stack", fields: [stackId], references: [id], map: "FK_f15d48fa3ea5e4bda05ca8ab207") + exifInfo Exif? + sharedLinks SharedLinkAsset[] + smartInfo SmartInfo? + smartSearch SmartSearch? + tags TagAsset[] + + @@unique([ownerId, libraryId, checksum], map: "UQ_assets_owner_library_checksum") + @@index([originalFileName], map: "IDX_4d66e76dada1ca180f67a205dc") + @@index([checksum], map: "IDX_8d3efe36c0755849395e6ea866") + @@index([id, stackId], map: "IDX_asset_id_stackId") + @@index([originalPath, libraryId], map: "IDX_originalPath_libraryId") + @@index([fileCreatedAt], map: "idx_asset_file_created_at") + @@map(name: "assets") +} + +model Audit { + id Int @id(map: "PK_1d3d120ddaf7bc9b1ed68ed463a") @default(autoincrement()) + entityType String @db.VarChar + entityId String @db.Uuid + action String @db.VarChar + ownerId String @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + + @@index([ownerId, createdAt], map: "IDX_ownerId_createdAt") + @@map(name: "audit") +} + +model Exif { + assetId String @id(map: "PK_c0117fdbc50b917ef9067740c44") @db.Uuid + make String? @db.VarChar + model String? @db.VarChar + exifImageWidth Int? + exifImageHeight Int? + fileSizeInByte BigInt? + orientation String? @db.VarChar + dateTimeOriginal DateTime? @db.Timestamptz(6) + modifyDate DateTime? @db.Timestamptz(6) + lensModel String? @db.VarChar + fNumber Float? + focalLength Float? + iso Int? + latitude Float? + longitude Float? + city String? @db.VarChar + state String? @db.VarChar + country String? @db.VarChar + description String @default("") + fps Float? + exposureTime String? @db.VarChar + livePhotoCID String? @db.VarChar + timeZone String? @db.VarChar + exifTextSearchableColumn Unsupported("tsvector") @default(dbgenerated("to_tsvector('english'::regconfig, (((((((((((((COALESCE(make, ''::character varying))::text || ' '::text) || (COALESCE(model, ''::character varying))::text) || ' '::text) || (COALESCE(orientation, ''::character varying))::text) || ' '::text) || (COALESCE(\"lensModel\", ''::character varying))::text) || ' '::text) || (COALESCE(city, ''::character varying))::text) || ' '::text) || (COALESCE(state, ''::character varying))::text) || ' '::text) || (COALESCE(country, ''::character varying))::text))")) + projectionType String? @db.VarChar + profileDescription String? @db.VarChar + colorspace String? @db.VarChar + bitsPerSample Int? + autoStackId String? @db.VarChar + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_c0117fdbc50b917ef9067740c44") + + @@index([autoStackId], map: "IDX_auto_stack_id") + @@index([livePhotoCID], map: "IDX_live_photo_cid") + @@index([city], map: "exif_city") + @@map(name: "exif") +} + +/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info. +model GeodataPlaces { + id Int @id(map: "PK_c29918988912ef4036f3d7fbff4") + name String @db.VarChar(200) + longitude Float + latitude Float + countryCode String @db.Char(2) + admin1Code String? @db.VarChar(20) + admin2Code String? @db.VarChar(80) + modificationDate DateTime @db.Date + earthCoord Unsupported("cube")? @default(dbgenerated("ll_to_earth(latitude, longitude)")) + admin1Name String? @db.VarChar + admin2Name String? @db.VarChar + alternateNames String? @db.VarChar + + @@index([earthCoord], map: "IDX_geodata_gist_earthcoord", type: Gist) + @@map(name: "geodata_places") +} + +model Libraries { + id String @id(map: "PK_505fedfcad00a09b3734b4223de") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + name String @db.VarChar + ownerId String @db.Uuid + type String @db.VarChar + importPaths String[] + exclusionPatterns String[] + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + deletedAt DateTime? @db.Timestamptz(6) + refreshedAt DateTime? @db.Timestamptz(6) + isVisible Boolean @default(true) + assets Assets[] + owner Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_0f6fc2fb195f24d19b0fb0d57c1") + + @@map(name: "libraries") +} + +model MoveHistory { + id String @id(map: "PK_af608f132233acf123f2949678d") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + entityId String @db.VarChar + pathType String @db.VarChar + oldPath String @db.VarChar + newPath String @unique(map: "UQ_newPath") @db.VarChar + + @@unique([entityId, pathType], map: "UQ_entityId_pathType") + @@map(name: "move_history") +} + +model Partners { + sharedById String @db.Uuid + sharedWithId String @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + inTimeline Boolean @default(false) + sharedBy Users @relation("partners_sharedByIdTousers", fields: [sharedById], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_7e077a8b70b3530138610ff5e04") + sharedWith Users @relation("partners_sharedWithIdTousers", fields: [sharedWithId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_d7e875c6c60e661723dbf372fd3") + + @@id([sharedById, sharedWithId], map: "PK_f1cc8f73d16b367f426261a8736") + @@map(name: "partners") +} + +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. +model Person { + id String @id(map: "PK_5fdaf670315c4b7e70cce85daa3") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + ownerId String @db.Uuid + name String @default("") @db.VarChar + thumbnailPath String @default("") @db.VarChar + isHidden Boolean @default(false) + birthDate DateTime? @db.Date + faceAssetId String? @db.Uuid + asset_faces_asset_faces_personIdToperson AssetFaces[] @relation("asset_faces_personIdToperson") + asset_faces_person_faceAssetIdToasset_faces AssetFaces? @relation("person_faceAssetIdToasset_faces", fields: [faceAssetId], references: [id], onUpdate: NoAction, map: "FK_2bbabe31656b6778c6b87b61023") + users Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_5527cc99f530a547093f9e577b6") + + @@map(name: "person") +} + +model SharedLinkAsset { + assetsId String @db.Uuid + sharedLinksId String @db.Uuid + assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_5b7decce6c8d3db9593d6111a66") + sharedLinks SharedLinks @relation(fields: [sharedLinksId], references: [id], onDelete: Cascade, map: "FK_c9fab4aa97ffd1b034f3d6581ab") + + @@id([assetsId, sharedLinksId], map: "PK_9b4f3687f9b31d1e311336b05e3") + @@index([assetsId], map: "IDX_5b7decce6c8d3db9593d6111a6") + @@index([sharedLinksId], map: "IDX_c9fab4aa97ffd1b034f3d6581a") + @@map(name: "shared_link__asset") +} + +model SharedLinks { + id String @id(map: "PK_642e2b0f619e4876e5f90a43465") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + description String? @db.VarChar + userId String @db.Uuid + key Bytes @unique(map: "UQ_sharedlink_key") + type String @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + expiresAt DateTime? @db.Timestamptz(6) + allowUpload Boolean @default(false) + albumId String? @db.Uuid + allowDownload Boolean @default(true) + showExif Boolean @default(true) + password String? @db.VarChar + assets SharedLinkAsset[] + albums Albums? @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "FK_0c6ce9058c29f07cdf7014eac66") + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_66fe3837414c5a9f1c33ca49340") + + @@index([albumId], map: "IDX_sharedlink_albumId") + @@index([key], map: "IDX_sharedlink_key") + @@map(name: "shared_links") +} + +model SmartInfo { + assetId String @id(map: "PK_5e3753aadd956110bf3ec0244ac") @db.Uuid + tags String[] + objects String[] + smartInfoTextSearchableColumn Unsupported("tsvector") @default(dbgenerated("to_tsvector('english'::regconfig, f_concat_ws(' '::text, (COALESCE(tags, ARRAY[]::text[]) || COALESCE(objects, ARRAY[]::text[]))))")) + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_5e3753aadd956110bf3ec0244ac") + + @@index([tags], map: "si_tags", type: Gin) + @@index([smartInfoTextSearchableColumn], map: "smart_info_text_searchable_idx", type: Gin) + @@map(name: "smart_info") +} + +model SmartSearch { + assetId String @id @db.Uuid + embedding Unsupported("vector") + assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@index([embedding], map: "clip_index") + @@map(name: "smart_search") +} + +model SocketIoAttachments { + id BigInt @unique @default(autoincrement()) + created_at DateTime? @default(now()) @db.Timestamptz(6) + payload Bytes? + + @@map(name: "socket_io_attachments") +} + +model SystemConfig { + key String @id(map: "PK_aab69295b445016f56731f4d535") @db.VarChar + value String? @db.VarChar + + @@map(name: "system_config") +} + +model SystemMetadata { + key String @id(map: "PK_fa94f6857470fb5b81ec6084465") @db.VarChar + value Json @default("{}") + + @@map(name: "system_metadata") +} + +model TagAsset { + assetsId String @db.Uuid + tagsId String @db.Uuid + tags Tags @relation(fields: [tagsId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_e99f31ea4cdf3a2c35c7287eb42") + assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_f8e8a9e893cb5c54907f1b798e9") + + @@id([assetsId, tagsId], map: "PK_ef5346fe522b5fb3bc96454747e") + @@index([tagsId], map: "IDX_e99f31ea4cdf3a2c35c7287eb4") + @@index([assetsId], map: "IDX_f8e8a9e893cb5c54907f1b798e") + @@index([assetsId, tagsId], map: "IDX_tag_asset_assetsId_tagsId") + @@map(name: "tag_asset") +} + +model Tags { + id String @id(map: "PK_e7dc17249a1148a1970748eda99") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + type String @db.VarChar + name String @db.VarChar + userId String @db.Uuid + renameTagId String? @db.Uuid + tags TagAsset[] + users Users @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_92e67dc508c705dd66c94615576") + + @@unique([name, userId], map: "UQ_tag_name_userId") + @@map(name: "tags") +} + +model UserToken { + id String @id(map: "PK_48cb6b5c20faa63157b3c1baf7f") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + token String @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) + userId String @db.Uuid + deviceType String @default("") @db.VarChar + deviceOS String @default("") @db.VarChar + users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_d37db50eecdf9b8ce4eedd2f918") + + @@map(name: "user_token") +} + +model Users { + id String @id(map: "PK_a3ffb1c0c8416b9fc6f907b7433") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + email String @unique(map: "UQ_97672ac88f789774dd47f7c8be3") @db.VarChar + password String @default("") @db.VarChar + createdAt DateTime @default(now()) @db.Timestamptz(6) + profileImagePath String @default("") @db.VarChar + isAdmin Boolean @default(false) + shouldChangePassword Boolean @default(true) + deletedAt DateTime? @db.Timestamptz(6) + oauthId String @default("") @db.VarChar + updatedAt DateTime @default(now()) @db.Timestamptz(6) + storageLabel String? @unique(map: "UQ_b309cf34fa58137c416b32cea3a") @db.VarChar + memoriesEnabled Boolean @default(true) + name String @default("") @db.VarChar + avatarColor String? @db.VarChar + quotaSizeInBytes BigInt? + quotaUsageInBytes BigInt @default(0) + status String @default("active") @db.VarChar + activity Activity[] + albums Albums[] + albumsSharedUsersUsers AlbumsSharedUsersUsers[] + apiKeys ApiKeys[] + assets Assets[] + libraries Libraries[] + sharedBy Partners[] @relation("partners_sharedByIdTousers") + sharedWith Partners[] @relation("partners_sharedWithIdTousers") + person Person[] + sharedLinks SharedLinks[] + tags Tags[] + userToken UserToken[] + + @@map(name: "users") +} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 592839254..a9c2e1230 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,15 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Prisma } from '@prisma/client'; import { DateTime } from 'luxon'; -import path from 'node:path'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { - AssetBuilderOptions, AssetCreate, AssetExploreFieldOptions, AssetPathEntity, @@ -30,161 +28,127 @@ import { WithoutProperty, } from 'src/interfaces/asset.interface'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; -import { OptionalBetween, searchAssetBuilder } from 'src/utils/database'; +import { searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; -import { - Brackets, - FindOptionsRelations, - FindOptionsSelect, - FindOptionsWhere, - In, - IsNull, - Not, - Repository, -} from 'typeorm'; - -const truncateMap: Record = { - [TimeBucketSize.DAY]: 'day', - [TimeBucketSize.MONTH]: 'month', -}; - -const dateTrunc = (options: TimeBucketOptions) => - `(date_trunc('${ - truncateMap[options.size] - }', (asset."localDateTime" at time zone 'UTC')) at time zone 'UTC')::timestamptz`; +import { Paginated, PaginationMode, PaginationOptions, paginatedBuilder, paginationHelper } from 'src/utils/pagination'; +import { Repository } from 'typeorm'; +import { PrismaRepository } from './prisma.repository'; @Instrumentation() @Injectable() export class AssetRepository implements IAssetRepository { constructor( @InjectRepository(AssetEntity) private repository: Repository, - @InjectRepository(ExifEntity) private exifRepository: Repository, - @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, - @InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository, + @Inject(PrismaRepository) private prismaRepository: PrismaRepository, ) {} - async upsertExif(exif: Partial): Promise { - await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); + async upsertExif(exif: Partial & { assetId: string }): Promise { + await this.prismaRepository.exif.upsert({ update: exif, create: exif, where: { assetId: exif.assetId } }); } - async upsertJobStatus(jobStatus: Partial): Promise { - await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); + async upsertJobStatus(jobStatus: Partial & { assetId: string }): Promise { + await this.prismaRepository.assetJobStatus.upsert({ + update: jobStatus, + create: jobStatus, + where: { assetId: jobStatus.assetId }, + }); } - create(asset: AssetCreate): Promise { - return this.repository.save(asset); - } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] }) - getByDate(ownerId: string, date: Date): Promise { - // For reference of a correct approach although slower - - // let builder = this.repository - // .createQueryBuilder('asset') - // .leftJoin('asset.exifInfo', 'exifInfo') - // .where('asset.ownerId = :ownerId', { ownerId }) - // .andWhere( - // `coalesce(date_trunc('day', asset."fileCreatedAt", "exifInfo"."timeZone") at TIME ZONE "exifInfo"."timeZone", date_trunc('day', asset."fileCreatedAt")) IN (:date)`, - // { date }, - // ) - // .andWhere('asset.isVisible = true') - // .andWhere('asset.isArchived = false') - // .orderBy('asset.fileCreatedAt', 'DESC'); - - // return builder.getMany(); - - return this.repository.find({ - where: { - ownerId, - isVisible: true, - isArchived: false, - resizePath: Not(IsNull()), - fileCreatedAt: OptionalBetween(date, DateTime.fromJSDate(date).plus({ day: 1 }).toJSDate()), - }, - relations: { - exifInfo: true, - }, - order: { - fileCreatedAt: 'DESC', + async create(asset: AssetCreate): Promise { + const { ownerId, libraryId, livePhotoVideoId, stackId, ...assetData } = asset; + const res = await this.prismaRepository.assets.create({ + data: { + ...assetData, + livePhotoVideo: livePhotoVideoId ? { connect: { id: livePhotoVideoId } } : undefined, + stack: stackId ? { connect: { id: stackId } } : undefined, + library: { connect: { id: libraryId } }, + owner: { connect: { id: ownerId } }, }, }); + return res as any as AssetEntity; } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { - return this.repository - .createQueryBuilder('entity') - .where( - `entity.ownerId IN (:...ownerIds) - AND entity.isVisible = true - AND entity.isArchived = false - AND entity.resizePath IS NOT NULL - AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day - AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, - { - ownerIds, - day, - month, + async getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + const date = DateTime.utc().set({ day, month }); + const res = await this.prismaRepository.assets.findMany({ + where: { + ownerId: { in: ownerIds }, + isVisible: true, + isArchived: false, + resizePath: { not: null }, + localDateTime: { + gte: date.startOf('day').toJSDate(), + lte: date.endOf('day').toJSDate(), }, - ) - .leftJoinAndSelect('entity.exifInfo', 'exifInfo') - .orderBy('entity.localDateTime', 'DESC') - .getMany(); - } - - @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - getByIds( - ids: string[], - relations?: FindOptionsRelations, - select?: FindOptionsSelect, - ): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations, - select, - withDeleted: true, + }, + include: { + exifInfo: true, + }, + orderBy: { + localDateTime: 'desc', + }, }); + + return res as any as AssetEntity[]; } @GenerateSql({ params: [[DummyValue.UUID]] }) @ChunkedArray() - getByIdsWithAllRelations(ids: string[]): Promise { - return this.repository.find({ - where: { id: In(ids) }, - relations: { + async getByIds(ids: string[], relations?: Prisma.AssetsInclude): Promise { + const res = await this.prismaRepository.assets.findMany({ + where: { id: { in: ids } }, + include: { + ...relations, + library: relations?.library ? { include: { assets: true, owner: true } } : undefined, + }, + }); + return res as any as AssetEntity[]; // typeorm type assumes arbitrary level of recursion + } + + @GenerateSql({ params: [[DummyValue.UUID]] }) + @ChunkedArray() + async getByIdsWithAllRelations(ids: string[]): Promise { + const res = await this.prismaRepository.assets.findMany({ + where: { id: { in: ids } }, + include: { exifInfo: true, smartInfo: true, tags: true, faces: { - person: true, - }, - stack: { - assets: true, + include: { + person: true, + }, }, + stack: { include: { assets: true } }, }, - withDeleted: true, }); + + return res as any as AssetEntity[]; } @GenerateSql({ params: [DummyValue.UUID] }) async deleteAll(ownerId: string): Promise { - await this.repository.delete({ ownerId }); + await this.prismaRepository.assets.deleteMany({ where: { ownerId } }); } - getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { - return paginate(this.repository, pagination, { + async getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { + const items = await this.prismaRepository.assets.findMany({ where: { albums: { - id: albumId, + some: { + id: albumId, + }, }, }, - relations: { - albums: true, - exifInfo: true, + orderBy: { + fileCreatedAt: 'desc', }, + skip: pagination.skip, + take: pagination.take + 1, }); + + return paginationHelper(items as any as AssetEntity[], pagination.take); } getByUserId( @@ -196,41 +160,22 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [[DummyValue.UUID]] }) - getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { - return paginate(this.repository, pagination, { + async getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { + const items = await this.prismaRepository.assets.findMany({ + where: { libraryId }, select: { id: true, originalPath: true, isOffline: true }, - where: { library: { id: libraryId } }, + orderBy: { fileCreatedAt: 'desc' }, + skip: pagination.skip, + take: pagination.take + 1, }); + + return paginationHelper(items as any as AssetPathEntity[], pagination.take); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { - return this.repository.findOne({ - where: { library: { id: libraryId }, originalPath: originalPath }, - }); - } - - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise { - const result = await this.repository.query( - ` - WITH paths AS (SELECT unnest($2::text[]) AS path) - SELECT path FROM paths - WHERE NOT EXISTS (SELECT 1 FROM assets WHERE "libraryId" = $1 AND "originalPath" = path); - `, - [libraryId, originalPaths], - ); - return result.map((row: { path: string }) => row.path); - } - - @GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] }) - @ChunkedArray({ paramIndex: 1 }) - async updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise { - await this.repository.update( - { library: { id: libraryId }, originalPath: Not(In(originalPaths)), isOffline: false }, - { isOffline: true }, - ); + async getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise { + const res = await this.prismaRepository.assets.findFirst({ where: { libraryId, originalPath } }); + return res as AssetEntity | null; } getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { @@ -253,74 +198,90 @@ export class AssetRepository implements IAssetRepository { */ @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) async getAllByDeviceId(ownerId: string, deviceId: string): Promise { - const items = await this.repository.find({ - select: { deviceAssetId: true }, + const items = await this.prismaRepository.assets.findMany({ where: { ownerId, deviceId, isVisible: true, }, - withDeleted: true, + select: { + deviceAssetId: true, + }, }); return items.map((asset) => asset.deviceAssetId); } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string, relations: FindOptionsRelations): Promise { - return this.repository.findOne({ - where: { id }, - relations, - // We are specifically asking for this asset. Return it even if it is soft deleted - withDeleted: true, - }); + async getById(id: string, relations: Prisma.AssetsInclude): Promise { + const items = await this.prismaRepository.assets.findFirst({ where: { id }, include: relations }); + return items as any as AssetEntity | null; } @GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] }) @Chunked() async updateAll(ids: string[], options: AssetUpdateAllOptions): Promise { - await this.repository.update({ id: In(ids) }, options); + await this.prismaRepository.assets.updateMany({ where: { id: { in: ids } }, data: options }); } @Chunked() async softDeleteAll(ids: string[]): Promise { - await this.repository.softDelete({ id: In(ids), isExternal: false }); + await this.prismaRepository.assets.updateMany({ where: { id: { in: ids } }, data: { deletedAt: new Date() } }); } @Chunked() async restoreAll(ids: string[]): Promise { - await this.repository.restore({ id: In(ids) }); + await this.prismaRepository.assets.updateMany({ where: { id: { in: ids } }, data: { deletedAt: null } }); } - async update(asset: AssetUpdateOptions): Promise { - await this.repository.update(asset.id, asset); + async update(asset: AssetUpdateOptions): Promise { + const { ownerId, libraryId, livePhotoVideoId, stackId, ...assetData } = asset; + + const res = await this.prismaRepository.assets.update({ + data: assetData, + where: { id: asset.id }, + include: { + exifInfo: true, + smartInfo: true, + tags: true, + faces: { + include: { + person: true, + }, + }, + }, + }); + + return res as any as AssetEntity; // typeorm type assumes all relations are included } async remove(asset: AssetEntity): Promise { - await this.repository.remove(asset); + await this.prismaRepository.assets.delete({ where: { id: asset.id } }); } @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) - getByChecksum(userId: string, checksum: Buffer): Promise { - return this.repository.findOne({ where: { ownerId: userId, checksum } }); + async getByChecksum(userId: string, checksum: Buffer): Promise { + const item = await this.prismaRepository.assets.findFirst({ where: { ownerId: userId, checksum: checksum } }); + return item as AssetEntity | null; } - findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { + async findLivePhotoMatch(options: LivePhotoSearchOptions): Promise { const { ownerId, otherAssetId, livePhotoCID, type } = options; - return this.repository.findOne({ + const item = await this.prismaRepository.assets.findFirst({ where: { - id: Not(otherAssetId), + id: { not: otherAssetId }, ownerId, type, exifInfo: { livePhotoCID, }, }, - relations: { + include: { exifInfo: true, }, }); + return item as AssetEntity | null; } @GenerateSql( @@ -331,68 +292,53 @@ export class AssetRepository implements IAssetRepository { params: [DummyValue.PAGINATION, property], })), ) - getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { - let relations: FindOptionsRelations = {}; - let where: FindOptionsWhere | FindOptionsWhere[] = {}; + async getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated { + let relations: Prisma.AssetsInclude = {}; + let where: Prisma.AssetsWhereInput = {}; switch (property) { case WithoutProperty.THUMBNAIL: { - where = [ - { resizePath: IsNull(), isVisible: true }, - { resizePath: '', isVisible: true }, - { webpPath: IsNull(), isVisible: true }, - { webpPath: '', isVisible: true }, - { thumbhash: IsNull(), isVisible: true }, - ]; + where = { + OR: [ + { resizePath: null, isVisible: true }, + { resizePath: '', isVisible: true }, + { webpPath: null, isVisible: true }, + { webpPath: '', isVisible: true }, + { thumbhash: null, isVisible: true }, + ], + }; break; } case WithoutProperty.ENCODED_VIDEO: { - where = [ - { type: AssetType.VIDEO, encodedVideoPath: IsNull() }, - { type: AssetType.VIDEO, encodedVideoPath: '' }, - ]; + where = { + OR: [ + { type: AssetType.VIDEO, encodedVideoPath: null }, + { type: AssetType.VIDEO, encodedVideoPath: '' }, + ], + }; break; } case WithoutProperty.EXIF: { relations = { exifInfo: true, - jobStatus: true, + assetJobStatus: true, }; where = { isVisible: true, - jobStatus: { - metadataExtractedAt: IsNull(), + assetJobStatus: { + metadataExtractedAt: null, }, }; break; } case WithoutProperty.SMART_SEARCH: { - relations = { - smartSearch: true, - }; where = { isVisible: true, - resizePath: Not(IsNull()), - smartSearch: { - embedding: IsNull(), - }, - }; - break; - } - - case WithoutProperty.OBJECT_TAGS: { - relations = { - smartInfo: true, - }; - where = { - resizePath: Not(IsNull()), - isVisible: true, - smartInfo: { - tags: IsNull(), - }, + resizePath: { not: null }, + smartSearch: null, }; break; } @@ -400,17 +346,18 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.FACES: { relations = { faces: true, - jobStatus: true, + assetJobStatus: true, }; where = { - resizePath: Not(IsNull()), + resizePath: { not: null }, isVisible: true, faces: { - assetId: IsNull(), - personId: IsNull(), + some: { + person: null, + }, }, - jobStatus: { - facesRecognizedAt: IsNull(), + assetJobStatus: { + facesRecognizedAt: null, }, }; break; @@ -421,21 +368,24 @@ export class AssetRepository implements IAssetRepository { faces: true, }; where = { - resizePath: Not(IsNull()), + resizePath: { not: null }, isVisible: true, faces: { - assetId: Not(IsNull()), - personId: IsNull(), + some: { + person: null, + }, }, }; break; } case WithoutProperty.SIDECAR: { - where = [ - { sidecarPath: IsNull(), isVisible: true }, - { sidecarPath: '', isVisible: true }, - ]; + where = { + OR: [ + { sidecarPath: null, isVisible: true }, + { sidecarPath: '', isVisible: true }, + ], + }; break; } @@ -444,29 +394,33 @@ export class AssetRepository implements IAssetRepository { } } - return paginate(this.repository, pagination, { - relations, + const items = await this.prismaRepository.assets.findMany({ where, - order: { + orderBy: { // Ensures correct order when paginating - createdAt: 'ASC', + createdAt: 'asc', }, + skip: pagination.skip, + take: pagination.take + 1, + include: relations, }); + + return paginationHelper(items as any as AssetEntity[], pagination.take); } - getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { - let where: FindOptionsWhere | FindOptionsWhere[] = {}; + async getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated { + let where: Prisma.AssetsWhereInput = {}; switch (property) { case WithProperty.SIDECAR: { - where = [{ sidecarPath: Not(IsNull()), isVisible: true }]; + where = { sidecarPath: { not: null }, isVisible: true }; break; } case WithProperty.IS_OFFLINE: { if (!libraryId) { throw new Error('Library id is required when finding offline assets'); } - where = [{ isOffline: true, libraryId: libraryId }]; + where = { isOffline: true, libraryId: libraryId }; break; } @@ -475,59 +429,84 @@ export class AssetRepository implements IAssetRepository { } } - return paginate(this.repository, pagination, { + const items = await this.prismaRepository.assets.findMany({ where, - order: { + orderBy: { // Ensures correct order when paginating - createdAt: 'ASC', + createdAt: 'asc', + }, + skip: pagination.skip, + take: pagination.take + 1, + }); + + return paginationHelper(items as any as AssetEntity[], pagination.take); + } + + async getFirstAssetForAlbumId(albumId: string): Promise { + const items = await this.prismaRepository.assets.findFirst({ + where: { + albums: { + some: { + id: albumId, + }, + }, + }, + orderBy: { + fileCreatedAt: 'desc', }, }); + + return items as AssetEntity | null; } - getFirstAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { fileCreatedAt: 'DESC' }, + async getLastUpdatedAssetForAlbumId(albumId: string): Promise { + const items = await this.prismaRepository.assets.findFirst({ + where: { + albums: { + some: { + id: albumId, + }, + }, + }, + orderBy: { + updatedAt: 'desc', + }, }); - } - getLastUpdatedAssetForAlbumId(albumId: string): Promise { - return this.repository.findOne({ - where: { albums: { id: albumId } }, - order: { updatedAt: 'DESC' }, - }); + return items as AssetEntity | null; } async getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; - const assets = await this.repository.find({ + const assets = await this.prismaRepository.assets.findMany({ select: { id: true, exifInfo: { - city: true, - state: true, - country: true, - latitude: true, - longitude: true, + select: { + city: true, + state: true, + country: true, + latitude: true, + longitude: true, + }, }, }, where: { - ownerId: In([...ownerIds]), + ownerId: { + in: ownerIds, + }, isVisible: true, isArchived, exifInfo: { - latitude: Not(IsNull()), - longitude: Not(IsNull()), + latitude: { not: null }, + longitude: { not: null }, }, isFavorite, - fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), + fileCreatedAt: { gte: fileCreatedAfter, lte: fileCreatedBefore }, }, - relations: { - exifInfo: true, - }, - order: { - fileCreatedAt: 'DESC', + orderBy: { + fileCreatedAt: 'desc', }, }); @@ -541,29 +520,20 @@ export class AssetRepository implements IAssetRepository { })); } - async getStatistics(ownerId: string, options: AssetStatsOptions): Promise { - let builder = this.repository - .createQueryBuilder('asset') - .select(`COUNT(asset.id)`, 'count') - .addSelect(`asset.type`, 'type') - .where('"ownerId" = :ownerId', { ownerId }) - .andWhere('asset.isVisible = true') - .groupBy('asset.type'); - - const { isArchived, isFavorite, isTrashed } = options; - if (isArchived !== undefined) { - builder = builder.andWhere(`asset.isArchived = :isArchived`, { isArchived }); - } - - if (isFavorite !== undefined) { - builder = builder.andWhere(`asset.isFavorite = :isFavorite`, { isFavorite }); - } - - if (isTrashed !== undefined) { - builder = builder.withDeleted().andWhere(`asset.deletedAt is not null`); - } - - const items = await builder.getRawMany(); + async getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise { + const items = await this.prismaRepository.assets.groupBy({ + by: 'type', + where: { + ownerId, + isVisible: true, + isArchived, + isFavorite, + deletedAt: isTrashed ? { not: null } : null, + }, + _count: { + id: true, + }, + }); const result: AssetStats = { [AssetType.AUDIO]: 0, @@ -573,46 +543,81 @@ export class AssetRepository implements IAssetRepository { }; for (const item of items) { - result[item.type as AssetType] = Number(item.count) || 0; + result[item.type as AssetType] = item._count.id; } return result; } - getRandom(ownerId: string, count: number): Promise { - // can't use queryBuilder because of custom OFFSET clause - return this.repository.query( - `SELECT * - FROM assets - WHERE "ownerId" = $1 - OFFSET FLOOR(RANDOM() * (SELECT GREATEST(COUNT(*) - $2, 0) FROM ASSETS WHERE "ownerId" = $1)) LIMIT $2`, - [ownerId, count], - ); + async getRandom(ownerId: string, take: number): Promise { + const where = { + ownerId, + isVisible: true, + }; + + const count = await this.prismaRepository.assets.count({ where }); + const skip = Math.floor(Math.random() * Math.max(count - take, 0)); + const items = await this.prismaRepository.assets.findMany({ where, take, skip }); + + return items as any as AssetEntity[]; } @GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] }) - getTimeBuckets(options: TimeBucketOptions): Promise { - const truncated = dateTrunc(options); - return this.getBuilder(options) - .select(`COUNT(asset.id)::int`, 'count') - .addSelect(truncated, 'timeBucket') - .groupBy(truncated) - .orderBy(truncated, options.order === AssetOrder.ASC ? 'ASC' : 'DESC') - .getRawMany(); + async getTimeBuckets(options: TimeBucketOptions): Promise { + const items = await this.prismaRepository.assets.groupBy({ + by: 'truncatedDate', + where: { + ownerId: { + in: options.userIds, + }, + isVisible: true, + isArchived: options.isArchived, + isFavorite: options.isFavorite, + deletedAt: options.isTrashed ? { not: null } : null, + albums: options.albumId ? { some: { id: options.albumId } } : undefined, + faces: options.personId ? { some: { personId: options.personId } } : undefined, + type: options.assetType, + }, + _count: { + id: true, + }, + orderBy: { + truncatedDate: 'desc', + }, + }); + + return items.map((item) => ({ + timeBucket: item.truncatedDate.toISOString(), + count: item._count.id, + })); } @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH }] }) - getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - const truncated = dateTrunc(options); - return ( - this.getBuilder(options) - .andWhere(`${truncated} = :timeBucket`, { timeBucket: timeBucket.replace(/^[+-]/, '') }) - // First sort by the day in localtime (put it in the right bucket) - .orderBy(truncated, 'DESC') - // and then sort by the actual time - .addOrderBy('asset.fileCreatedAt', options.order === AssetOrder.ASC ? 'ASC' : 'DESC') - .getMany() - ); + async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { + const items = await this.prismaRepository.assets.findMany({ + where: { + ownerId: { + in: options.userIds, + }, + isVisible: true, + isArchived: options.isArchived, + isFavorite: options.isFavorite, + deletedAt: options.isTrashed ? { not: null } : null, + truncatedDate: timeBucket.replace(/^[+-]/, ''), + albums: options.albumId ? { some: { id: options.albumId } } : undefined, + faces: options.personId ? { some: { personId: options.personId } } : undefined, + type: options.assetType, + }, + orderBy: { fileCreatedAt: options.order === AssetOrder.ASC ? 'asc' : 'desc' }, + include: { + owner: true, + exifInfo: options.exifInfo, + stack: options.withStacked ? { include: { assets: true } } : undefined, + }, + relationLoadStrategy: 'query', // this seems faster than 'join' in this case + }); + + return items as any as AssetEntity[]; } @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) @@ -620,28 +625,44 @@ export class AssetRepository implements IAssetRepository { ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions, ): Promise> { - const cte = this.exifRepository - .createQueryBuilder('e') - .select('city') - .groupBy('city') - .having('count(city) >= :minAssetsPerField', { minAssetsPerField }); + const res = await this.prismaRepository.exif.groupBy({ + by: 'city', + where: { + assets: { ownerId, isVisible: true, isArchived: false, type: AssetType.IMAGE }, + city: { not: null }, + }, + having: { + assetId: { + _count: { + gte: minAssetsPerField, + }, + }, + }, + take: maxFields, + orderBy: { + city: 'desc', + }, + }); - const items = await this.getBuilder({ - userIds: [ownerId], - exifInfo: false, - assetType: AssetType.IMAGE, - isArchived: false, - }) - .select('c.city', 'value') - .addSelect('asset.id', 'data') - .distinctOn(['c.city']) - .innerJoin('exif', 'e', 'asset.id = e."assetId"') - .addCommonTableExpression(cte, 'cities') - .innerJoin('cities', 'c', 'c.city = e.city') - .limit(maxFields) - .getRawMany(); + const cities = res.map((item) => item.city!); - return { fieldName: 'exifInfo.city', items }; + const items = await this.prismaRepository.exif.findMany({ + where: { + city: { + in: cities, + }, + }, + select: { + city: true, + assetId: true, + }, + distinct: ['city'], + }); + + return { + fieldName: 'exifInfo.city', + items: items.map((item) => ({ value: item.city!, data: item.assetId })), + }; } @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) @@ -649,85 +670,42 @@ export class AssetRepository implements IAssetRepository { ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions, ): Promise> { - const cte = this.smartInfoRepository - .createQueryBuilder('si') - .select('unnest(tags)', 'tag') - .groupBy('tag') - .having('count(*) >= :minAssetsPerField', { minAssetsPerField }); + const res = await this.prismaRepository.smartInfo.groupBy({ + by: 'tags', + where: { + assets: { ownerId, isVisible: true, isArchived: false, type: AssetType.IMAGE }, + }, + having: { + assetId: { + _count: { + gte: minAssetsPerField, + }, + }, + }, + take: maxFields, + orderBy: { + tags: 'desc', + }, + }); - const items = await this.getBuilder({ - userIds: [ownerId], - exifInfo: false, - assetType: AssetType.IMAGE, - isArchived: false, - }) - .select('unnest(si.tags)', 'value') - .addSelect('asset.id', 'data') - .distinctOn(['unnest(si.tags)']) - .innerJoin('smart_info', 'si', 'asset.id = si."assetId"') - .addCommonTableExpression(cte, 'random_tags') - .innerJoin('random_tags', 't', 'si.tags @> ARRAY[t.tag]') - .limit(maxFields) - .getRawMany(); + const tags = res.flatMap((item) => item.tags!); - return { fieldName: 'smartInfo.tags', items }; - } + const items = await this.prismaRepository.smartInfo.findMany({ + where: { + tags: { + hasSome: tags, + }, + }, + select: { + tags: true, + assetId: true, + }, + }); - private getBuilder(options: AssetBuilderOptions) { - const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options; - - let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); - if (assetType !== undefined) { - builder = builder.andWhere('asset.type = :assetType', { assetType }); - } - - let stackJoined = false; - - if (exifInfo !== false) { - stackJoined = true; - builder = builder - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .leftJoinAndSelect('asset.stack', 'stack') - .leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - - if (albumId) { - builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); - } - - if (userIds) { - builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds }); - } - - if (isArchived !== undefined) { - builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived }); - } - - if (isFavorite !== undefined) { - builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite }); - } - - if (isTrashed !== undefined) { - builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); - } - - if (personId !== undefined) { - builder = builder - .innerJoin('asset.faces', 'faces') - .innerJoin('faces.person', 'person') - .andWhere('person.id = :personId', { personId }); - } - - if (withStacked) { - if (!stackJoined) { - builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); - } - builder = builder.andWhere( - new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL')), - ); - } - - return builder; + return { + fieldName: 'smartInfo.tags', + items: items.map((item) => ({ value: item.tags![0], data: item.assetId })), + }; } @GenerateSql({ params: [DummyValue.STRING, [DummyValue.UUID], { numResults: 250 }] }) @@ -736,85 +714,80 @@ export class AssetRepository implements IAssetRepository { userIds: string[], { numResults }: MetadataSearchOptions, ): Promise { - const rows = await this.getBuilder({ - userIds: userIds, - exifInfo: false, - isArchived: false, - }) - .select('asset.*') - .addSelect('e.*') - .addSelect('COALESCE(si.tags, array[]::text[])', 'tags') - .addSelect('COALESCE(si.objects, array[]::text[])', 'objects') - .innerJoin('exif', 'e', 'asset."id" = e."assetId"') - .leftJoin('smart_info', 'si', 'si."assetId" = asset."id"') - .andWhere( - new Brackets((qb) => { - qb.where( - `(e."exifTextSearchableColumn" || COALESCE(si."smartInfoTextSearchableColumn", to_tsvector('english', ''))) - @@ PLAINTO_TSQUERY('english', :query)`, - { query }, - ).orWhere('asset."originalFileName" = :path', { path: path.parse(query).name }); - }), - ) - .addOrderBy('asset.fileCreatedAt', 'DESC') - .limit(numResults) - .getRawMany(); + const items = await this.prismaRepository.assets.findMany({ + where: { + ownerId: { + in: userIds, + }, + isVisible: true, + isArchived: false, + OR: [ + { + originalFileName: { + contains: query, + }, + }, + { + exifInfo: { + city: { + contains: query, + }, + }, + }, + { + exifInfo: { + description: { + contains: query, + }, + }, + }, + { + exifInfo: { + lensModel: { + contains: query, + }, + }, + }, + { + exifInfo: { + make: { + contains: query, + }, + }, + }, + { + exifInfo: { + model: { + contains: query, + }, + }, + }, + { + exifInfo: { + state: { + contains: query, + }, + }, + }, + { + exifInfo: { + country: { + contains: query, + }, + }, + }, + ], + }, + orderBy: { + fileCreatedAt: 'desc', + }, + take: numResults, + include: { + exifInfo: true, + smartInfo: true, + }, + }); - return rows.map( - ({ - tags, - objects, - country, - state, - city, - description, - model, - make, - dateTimeOriginal, - exifImageHeight, - exifImageWidth, - exposureTime, - fNumber, - fileSizeInByte, - focalLength, - iso, - latitude, - lensModel, - longitude, - modifyDate, - projectionType, - timeZone, - ...assetInfo - }) => - ({ - exifInfo: { - city, - country, - dateTimeOriginal, - description, - exifImageHeight, - exifImageWidth, - exposureTime, - fNumber, - fileSizeInByte, - focalLength, - iso, - latitude, - lensModel, - longitude, - make, - model, - modifyDate, - projectionType, - state, - timeZone, - }, - smartInfo: { - tags, - objects, - }, - ...assetInfo, - }) as AssetEntity, - ); + return items as any as AssetEntity[]; } } diff --git a/server/src/repositories/prisma.repository.ts b/server/src/repositories/prisma.repository.ts new file mode 100644 index 000000000..e362abd70 --- /dev/null +++ b/server/src/repositories/prisma.repository.ts @@ -0,0 +1,20 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { findNonDeletedExtension } from '../prisma/find-non-deleted'; +import { metricsExtension } from '../prisma/metrics'; + +@Injectable() +export class PrismaRepository extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super(); + return this.$extends(metricsExtension).$extends(findNonDeletedExtension) as this; + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/server/src/services/asset-v1.service.ts b/server/src/services/asset-v1.service.ts index a24ddbd69..3a0437629 100644 --- a/server/src/services/asset-v1.service.ts +++ b/server/src/services/asset-v1.service.ts @@ -341,7 +341,7 @@ export class AssetServiceV1 { isArchived: dto.isArchived ?? false, duration: dto.duration || null, isVisible: dto.isVisible ?? true, - livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity), + livePhotoVideoId: livePhotoAssetId, originalFileName: file.originalName, sidecarPath: sidecarPath || null, isReadOnly: dto.isReadOnly ?? false, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 7020d5061..a8890a95d 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -6,11 +6,19 @@ import sanitize from 'sanitize-filename'; import { AccessCore, Permission } from 'src/cores/access.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; +import { AccessCore, Permission } from 'src/cores/access.core'; +import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetResponseDto, MemoryLaneResponseDto, SanitizedAssetResponseDto, mapAsset, +} from 'src/dtos/asset-response.dto'; + AssetResponseDto, + MemoryLaneResponseDto, + SanitizedAssetResponseDto, + mapAsset, } from 'src/dtos/asset-response.dto'; import { AssetBulkDeleteDto, @@ -20,6 +28,7 @@ import { AssetStatsDto, UpdateAssetDto, UploadFieldName, + UploadFieldName, mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -280,11 +289,15 @@ export class AssetService { smartInfo: true, owner: true, faces: { - person: true, + include: { person: true }, }, stack: { - assets: { - exifInfo: true, + include: { + assets: { + include: { + exifInfo: true, + }, + }, }, }, }); @@ -316,16 +329,7 @@ export class AssetService { const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto; await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); - await this.assetRepository.update({ id, ...rest }); - const asset = await this.assetRepository.getById(id, { - exifInfo: true, - owner: true, - smartInfo: true, - tags: true, - faces: { - person: true, - }, - }); + const asset = await this.assetRepository.update({ id, ...rest }); if (!asset) { throw new BadRequestException('Asset not found'); } @@ -351,14 +355,16 @@ export class AssetService { } else if (options.stackParentId) { //Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId); - const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } }); + const primaryAsset = await this.assetRepository.getById(options.stackParentId, { + stack: { include: { assets: true } }, + }); if (!primaryAsset) { throw new BadRequestException('Asset not found for given stackParentId'); } let stack = primaryAsset.stack; ids.push(options.stackParentId); - const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } }); + const assets = await this.assetRepository.getByIds(ids, { stack: { include: { assets: true } } }); stackIdsToCheckForDelete.push( ...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)), ); @@ -422,10 +428,10 @@ export class AssetService { const asset = await this.assetRepository.getById(id, { faces: { - person: true, + include: { person: true }, }, library: true, - stack: { assets: true }, + stack: { include: { assets: true } }, exifInfo: true, }); @@ -494,11 +500,11 @@ export class AssetService { const childIds: string[] = []; const oldParent = await this.assetRepository.getById(oldParentId, { faces: { - person: true, + include: { person: true }, }, library: true, stack: { - assets: true, + include: { assets: true }, }, }); if (!oldParent?.stackId) { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 504716a55..88b8ea601 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -308,13 +308,7 @@ export class PersonService { return JobStatus.SKIPPED; } - const relations = { - exifInfo: true, - faces: { - person: false, - }, - }; - const [asset] = await this.assetRepository.getByIds([id], relations); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, faces: true }); if (!asset || !asset.resizePath || asset.faces?.length > 0) { return JobStatus.FAILED; } diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index dec1a9de0..6ca276764 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -37,7 +37,10 @@ export async function* usePagination( } } -function paginationHelper(items: Entity[], take: number): PaginationResult { +export function paginationHelper( + items: Entity[], + take: number, +): PaginationResult { const hasNextPage = items.length > take; items.splice(take); diff --git a/server/src/utils/sql.ts b/server/src/utils/sql.ts index 1afe4d5a8..c126c54f3 100644 --- a/server/src/utils/sql.ts +++ b/server/src/utils/sql.ts @@ -19,6 +19,7 @@ import { LibraryRepository } from 'src/repositories/library.repository'; import { MoveRepository } from 'src/repositories/move.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PrismaRepository } from 'src/repositories/prisma.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { SystemConfigRepository } from 'src/repositories/system-config.repository'; @@ -62,6 +63,7 @@ const repositories = [ MoveRepository, PartnerRepository, PersonRepository, + PrismaRepository, SharedLinkRepository, SearchRepository, SystemConfigRepository,