2025-01-09 11:15:41 -05:00
|
|
|
import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, Selectable, SelectQueryBuilder, sql } from 'kysely';
|
|
|
|
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
|
|
|
import { Assets, DB } from 'src/db';
|
2024-03-20 16:02:51 -05:00
|
|
|
import { AlbumEntity } from 'src/entities/album.entity';
|
|
|
|
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
2024-08-19 20:03:33 -04:00
|
|
|
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
2024-03-20 16:02:51 -05:00
|
|
|
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
|
|
|
|
import { ExifEntity } from 'src/entities/exif.entity';
|
|
|
|
|
import { LibraryEntity } from 'src/entities/library.entity';
|
|
|
|
|
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
|
|
|
|
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
2024-07-05 09:08:36 -04:00
|
|
|
import { StackEntity } from 'src/entities/stack.entity';
|
2024-03-20 16:02:51 -05:00
|
|
|
import { TagEntity } from 'src/entities/tag.entity';
|
|
|
|
|
import { UserEntity } from 'src/entities/user.entity';
|
2025-01-09 11:15:41 -05:00
|
|
|
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
|
|
|
|
|
import { TimeBucketSize } from 'src/interfaces/asset.interface';
|
|
|
|
|
import { AssetSearchBuilderOptions } from 'src/interfaces/search.interface';
|
|
|
|
|
import { anyUuid, asUuid } from 'src/utils/database';
|
2023-02-06 10:24:58 -06:00
|
|
|
import {
|
|
|
|
|
Column,
|
2023-02-19 16:44:53 +00:00
|
|
|
CreateDateColumn,
|
2023-10-06 07:01:14 +00:00
|
|
|
DeleteDateColumn,
|
2023-02-06 10:24:58 -06:00
|
|
|
Entity,
|
|
|
|
|
Index,
|
2023-02-19 16:44:53 +00:00
|
|
|
JoinColumn,
|
2023-02-06 10:24:58 -06:00
|
|
|
JoinTable,
|
|
|
|
|
ManyToMany,
|
2023-02-19 16:44:53 +00:00
|
|
|
ManyToOne,
|
2023-05-17 13:07:17 -04:00
|
|
|
OneToMany,
|
2023-02-06 10:24:58 -06:00
|
|
|
OneToOne,
|
|
|
|
|
PrimaryGeneratedColumn,
|
|
|
|
|
UpdateDateColumn,
|
|
|
|
|
} from 'typeorm';
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2024-05-20 18:09:10 -04:00
|
|
|
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
2023-09-20 13:16:33 +02:00
|
|
|
|
2022-02-03 10:06:44 -06:00
|
|
|
@Entity('assets')
|
2023-09-20 13:16:33 +02:00
|
|
|
// Checksums must be unique per user and library
|
2024-05-20 18:09:10 -04:00
|
|
|
@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'checksum'], {
|
2023-09-20 13:16:33 +02:00
|
|
|
unique: true,
|
2024-05-20 18:09:10 -04:00
|
|
|
where: '"libraryId" IS NULL',
|
|
|
|
|
})
|
|
|
|
|
@Index('UQ_assets_owner_library_checksum' + '', ['owner', 'library', 'checksum'], {
|
|
|
|
|
unique: true,
|
|
|
|
|
where: '"libraryId" IS NOT NULL',
|
2023-09-20 13:16:33 +02:00
|
|
|
})
|
2025-01-09 11:15:41 -05:00
|
|
|
@Index('idx_local_date_time', { synchronize: false })
|
|
|
|
|
@Index('idx_local_date_time_month', { synchronize: false })
|
2023-10-10 15:38:26 +02:00
|
|
|
@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId'])
|
2024-03-14 01:58:09 -04:00
|
|
|
@Index('IDX_asset_id_stackId', ['id', 'stackId'])
|
2024-03-06 21:36:08 -06:00
|
|
|
@Index('idx_originalFileName_trigram', { synchronize: false })
|
2023-09-20 13:16:33 +02:00
|
|
|
// For all assets, each originalpath must be unique per user and library
|
2022-02-03 10:06:44 -06:00
|
|
|
export class AssetEntity {
|
|
|
|
|
@PrimaryGeneratedColumn('uuid')
|
2022-06-25 19:53:06 +02:00
|
|
|
id!: string;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
|
|
|
|
@Column()
|
2022-06-25 19:53:06 +02:00
|
|
|
deviceAssetId!: string;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2023-02-27 18:28:45 -06:00
|
|
|
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
2023-02-19 16:44:53 +00:00
|
|
|
owner!: UserEntity;
|
|
|
|
|
|
2022-02-03 10:06:44 -06:00
|
|
|
@Column()
|
2023-02-19 16:44:53 +00:00
|
|
|
ownerId!: string;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2024-05-20 18:09:10 -04:00
|
|
|
@ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
|
|
|
|
library?: LibraryEntity | null;
|
2023-09-20 13:16:33 +02:00
|
|
|
|
2024-05-20 18:09:10 -04:00
|
|
|
@Column({ nullable: true })
|
|
|
|
|
libraryId?: string | null;
|
2023-09-20 13:16:33 +02:00
|
|
|
|
2022-02-03 10:06:44 -06:00
|
|
|
@Column()
|
2022-06-25 19:53:06 +02:00
|
|
|
deviceId!: string;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
|
|
|
|
@Column()
|
2022-06-25 19:53:06 +02:00
|
|
|
type!: AssetType;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2024-09-18 09:57:52 -04:00
|
|
|
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
|
|
|
|
|
status!: AssetStatus;
|
|
|
|
|
|
2023-09-20 13:16:33 +02:00
|
|
|
@Column()
|
2022-06-25 19:53:06 +02:00
|
|
|
originalPath!: string;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2024-08-19 20:03:33 -04:00
|
|
|
@OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset)
|
|
|
|
|
files!: AssetFileEntity[];
|
2022-05-22 06:56:36 -05:00
|
|
|
|
2023-06-17 23:22:31 -04:00
|
|
|
@Column({ type: 'bytea', nullable: true })
|
|
|
|
|
thumbhash!: Buffer | null;
|
|
|
|
|
|
2022-07-04 20:20:43 +01:00
|
|
|
@Column({ type: 'varchar', nullable: true, default: '' })
|
2023-01-30 11:14:13 -05:00
|
|
|
encodedVideoPath!: string | null;
|
2022-06-04 18:34:11 -05:00
|
|
|
|
2023-02-19 16:44:53 +00:00
|
|
|
@CreateDateColumn({ type: 'timestamptz' })
|
2023-05-29 16:05:14 +02:00
|
|
|
createdAt!: Date;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2023-02-06 10:24:58 -06:00
|
|
|
@UpdateDateColumn({ type: 'timestamptz' })
|
2023-05-29 16:05:14 +02:00
|
|
|
updatedAt!: Date;
|
2023-02-06 10:24:58 -06:00
|
|
|
|
2023-10-06 07:01:14 +00:00
|
|
|
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
|
|
|
|
|
deletedAt!: Date | null;
|
|
|
|
|
|
2024-02-18 13:22:25 -05:00
|
|
|
@Index('idx_asset_file_created_at')
|
2023-02-19 16:44:53 +00:00
|
|
|
@Column({ type: 'timestamptz' })
|
2023-05-29 16:05:14 +02:00
|
|
|
fileCreatedAt!: Date;
|
2023-02-19 16:44:53 +00:00
|
|
|
|
2023-10-06 08:12:09 -04:00
|
|
|
@Column({ type: 'timestamptz' })
|
2023-10-04 18:11:11 -04:00
|
|
|
localDateTime!: Date;
|
|
|
|
|
|
2023-02-19 16:44:53 +00:00
|
|
|
@Column({ type: 'timestamptz' })
|
2023-05-29 16:05:14 +02:00
|
|
|
fileModifiedAt!: Date;
|
2023-02-19 16:44:53 +00:00
|
|
|
|
2022-02-03 10:06:44 -06:00
|
|
|
@Column({ type: 'boolean', default: false })
|
2022-06-25 19:53:06 +02:00
|
|
|
isFavorite!: boolean;
|
2022-02-03 10:06:44 -06:00
|
|
|
|
2023-04-12 18:37:52 +03:00
|
|
|
@Column({ type: 'boolean', default: false })
|
|
|
|
|
isArchived!: boolean;
|
|
|
|
|
|
2023-09-20 13:16:33 +02:00
|
|
|
@Column({ type: 'boolean', default: false })
|
|
|
|
|
isExternal!: boolean;
|
|
|
|
|
|
|
|
|
|
@Column({ type: 'boolean', default: false })
|
|
|
|
|
isOffline!: boolean;
|
|
|
|
|
|
2023-05-24 23:08:21 +02:00
|
|
|
@Column({ type: 'bytea' })
|
|
|
|
|
@Index()
|
|
|
|
|
checksum!: Buffer; // sha1 checksum
|
2022-08-31 21:27:17 +07:00
|
|
|
|
2022-06-25 19:53:06 +02:00
|
|
|
@Column({ type: 'varchar', nullable: true })
|
|
|
|
|
duration!: string | null;
|
2022-02-10 20:40:11 -06:00
|
|
|
|
2022-11-18 23:12:54 -06:00
|
|
|
@Column({ type: 'boolean', default: true })
|
|
|
|
|
isVisible!: boolean;
|
|
|
|
|
|
2024-06-27 15:41:49 -04:00
|
|
|
@ManyToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
|
2023-02-19 16:44:53 +00:00
|
|
|
@JoinColumn()
|
|
|
|
|
livePhotoVideo!: AssetEntity | null;
|
|
|
|
|
|
|
|
|
|
@Column({ nullable: true })
|
2022-11-18 23:12:54 -06:00
|
|
|
livePhotoVideoId!: string | null;
|
|
|
|
|
|
2023-04-11 05:23:39 -05:00
|
|
|
@Column({ type: 'varchar' })
|
fix(server): add filename search (#6394)
Fixes https://github.com/immich-app/immich/issues/5982.
There are basically three options:
1. Search `originalFileName` by dropping a file extension from the query
(if present). Lower fidelity but very easy - just a standard index &
equality.
2. Search `originalPath` by adding an index on `reverse(originalPath)`
and using `starts_with(reverse(query) + "/", reverse(originalPath)`. A
weird index & query but high fidelity.
3. Add a new generated column called `originalFileNameWithExtension` or
something. More storage, kinda jank.
TBH, I think (1) is good enough and easy to make better in the future.
For example, if I search "DSC_4242.jpg", I don't really think it matters
if "DSC_4242.mov" also shows up.
edit: There's a fourth approach that we discussed a bit in Discord and
decided we could switch to it in the future: using a GIN. The minor
issue is that Postgres doesn't tokenize paths in a useful (they're a
single token and it won't match against partial components). We can
solve that by tokenizing it ourselves. For example:
```
immich=# with vecs as (select to_tsvector('simple', array_to_string(string_to_array('upload/library/sushain/2015/2015-08-09/IMG_275.JPG', '/'), ' ')) as vec) select * from vecs where vec @@ phraseto_tsquery('simple', array_to_string(string_to_array('library/sushain', '/'), ' '));
vec
-------------------------------------------------------------------------------
'-08':6 '-09':7 '2015':4,5 'img_275.jpg':8 'library':2 'sushain':3 'upload':1
(1 row)
```
The query is also tokenized with the 'split-by-slash-join-with-space'
strategy. This strategy results in `IMG_275.JPG`, `2015`, `sushain` and
`library/sushain` matching. But, `08` and `IMG_275` do not match. The
former is because the token is `-08` and the latter because the
`img_275.jpg` token is matched against exactly.
2024-01-15 12:40:28 -08:00
|
|
|
@Index()
|
2023-04-11 05:23:39 -05:00
|
|
|
originalFileName!: string;
|
|
|
|
|
|
feat(server): xmp sidecar metadata (#2466)
* initial commit for XMP sidecar support
* Added support for 'missing' metadata files to include those without sidecar files, now detects sidecar files in the filesystem for media already ingested but the sidecar was created afterwards
* didn't mean to commit default log level during testing
* new sidecar logic for video metadata as well
* Added xml mimetype for sidecars only
* don't need capture group for this regex
* wrong default value reverted
* simplified the move here - keep it in the same try catch since the outcome is to move the media back anyway
* simplified setter logic
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
* simplified logic per suggestions
* sidecar is now its own queue with a discover and sync, updated UI for the new job queueing
* queue a sidecar job for every asset based on discovery or sync, though the logic is almost identical aside from linking the sidecar
* now queue sidecar jobs for each assset, though logic is mostly the same between discovery and sync
* simplified logic of filename extraction and asset instantiation
* not sure how that got deleted..
* updated code per suggestions and comments in the PR
* stat was not being used, removed the variable set
* better type checking, using in-scope variables for exif getter instead of passing in every time
* removed commented out test
* ran and resolved all lints, formats, checks, and tests
* resolved suggested change in PR
* made getExifProperty more dynamic with multiple possible args for fallbacks, fixed typo, used generic in function for better type checking
* better error handling and moving files back to positions on move or save failure
* regenerated api
* format fixes
* Added XMP documentation
* documentation typo
* Merged in main
* missed merge conflict
* more changes due to a merge
* Resolving conflicts
* added icon for sidecar jobs
---------
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-05-24 21:59:30 -04:00
|
|
|
@Column({ type: 'varchar', nullable: true })
|
|
|
|
|
sidecarPath!: string | null;
|
|
|
|
|
|
2022-02-10 20:40:11 -06:00
|
|
|
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
|
2022-06-25 19:53:06 +02:00
|
|
|
exifInfo?: ExifEntity;
|
2022-02-19 22:42:10 -06:00
|
|
|
|
2023-12-08 11:15:46 -05:00
|
|
|
@OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset)
|
|
|
|
|
smartSearch?: SmartSearchEntity;
|
|
|
|
|
|
2023-02-27 18:28:45 -06:00
|
|
|
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
|
2024-03-14 01:58:09 -04:00
|
|
|
@JoinTable({ name: 'tag_asset', synchronize: false })
|
2022-12-05 11:56:44 -06:00
|
|
|
tags!: TagEntity[];
|
2023-01-09 14:16:08 -06:00
|
|
|
|
2023-02-27 18:28:45 -06:00
|
|
|
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
|
2023-01-09 14:16:08 -06:00
|
|
|
@JoinTable({ name: 'shared_link__asset' })
|
|
|
|
|
sharedLinks!: SharedLinkEntity[];
|
2023-03-26 04:46:48 +02:00
|
|
|
|
2023-04-09 21:48:01 -05:00
|
|
|
@ManyToMany(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
2023-03-26 04:46:48 +02:00
|
|
|
albums?: AlbumEntity[];
|
2023-05-17 13:07:17 -04:00
|
|
|
|
|
|
|
|
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
|
|
|
|
|
faces!: AssetFaceEntity[];
|
2023-10-22 02:38:07 +00:00
|
|
|
|
|
|
|
|
@Column({ nullable: true })
|
2024-01-27 18:52:14 +00:00
|
|
|
stackId?: string | null;
|
2023-10-22 02:38:07 +00:00
|
|
|
|
2024-07-05 09:08:36 -04:00
|
|
|
@ManyToOne(() => StackEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
|
2024-01-27 18:52:14 +00:00
|
|
|
@JoinColumn()
|
2024-07-05 09:08:36 -04:00
|
|
|
stack?: StackEntity | null;
|
2023-11-09 17:55:00 -08:00
|
|
|
|
|
|
|
|
@OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true })
|
|
|
|
|
jobStatus?: AssetJobStatusEntity;
|
2024-05-16 13:08:37 -04:00
|
|
|
|
|
|
|
|
@Index('IDX_assets_duplicateId')
|
|
|
|
|
@Column({ type: 'uuid', nullable: true })
|
|
|
|
|
duplicateId!: string | null;
|
2022-02-03 10:06:44 -06:00
|
|
|
}
|
2025-01-09 11:15:41 -05:00
|
|
|
|
|
|
|
|
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
|
|
|
|
return qb
|
|
|
|
|
.leftJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
|
|
|
|
return qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.select((eb) => eb.fn('to_jsonb', [eb.table('exif')]).as('exifInfo'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
|
|
|
|
|
return qb
|
|
|
|
|
.leftJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
|
|
|
|
.select(sql<number[]>`smart_search.embedding`.as('embedding'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withFaces(eb: ExpressionBuilder<DB, 'assets'>) {
|
|
|
|
|
return jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as(
|
|
|
|
|
'faces',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withFiles(eb: ExpressionBuilder<DB, 'assets'>, type?: AssetFileType) {
|
|
|
|
|
return jsonArrayFrom(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('asset_files')
|
|
|
|
|
.selectAll()
|
|
|
|
|
.whereRef('asset_files.assetId', '=', 'assets.id')
|
|
|
|
|
.$if(!!type, (qb) => qb.where('type', '=', type!)),
|
|
|
|
|
).as('files');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withFacesAndPeople(eb: ExpressionBuilder<DB, 'assets'>) {
|
|
|
|
|
return eb
|
|
|
|
|
.selectFrom('asset_faces')
|
|
|
|
|
.leftJoin('person', 'person.id', 'asset_faces.personId')
|
|
|
|
|
.whereRef('asset_faces.assetId', '=', 'assets.id')
|
|
|
|
|
.select((eb) =>
|
|
|
|
|
eb
|
|
|
|
|
.fn('jsonb_agg', [
|
|
|
|
|
eb
|
|
|
|
|
.case()
|
|
|
|
|
.when('person.id', 'is not', null)
|
|
|
|
|
.then(
|
|
|
|
|
eb.fn('jsonb_insert', [
|
|
|
|
|
eb.fn('to_jsonb', [eb.table('asset_faces')]),
|
|
|
|
|
sql`'{person}'::text[]`,
|
|
|
|
|
eb.fn('to_jsonb', [eb.table('person')]),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.else(eb.fn('to_jsonb', [eb.table('asset_faces')]))
|
|
|
|
|
.end(),
|
|
|
|
|
])
|
|
|
|
|
.as('faces'),
|
|
|
|
|
)
|
|
|
|
|
.as('faces');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Adds a `has_people` CTE that can be inner joined on to filter out assets */
|
|
|
|
|
export function hasPeopleCte(db: Kysely<DB>, personIds: string[]) {
|
|
|
|
|
return db.with('has_people', (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.selectFrom('asset_faces')
|
|
|
|
|
.select('assetId')
|
|
|
|
|
.where('personId', '=', anyUuid(personIds!))
|
|
|
|
|
.groupBy('assetId')
|
|
|
|
|
.having((eb) => eb.fn.count('personId'), '>=', personIds.length),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasPeople(db: Kysely<DB>, personIds?: string[]) {
|
|
|
|
|
return personIds && personIds.length > 0
|
|
|
|
|
? hasPeopleCte(db, personIds).selectFrom('assets').innerJoin('has_people', 'has_people.assetId', 'assets.id')
|
|
|
|
|
: db.selectFrom('assets');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withOwner(eb: ExpressionBuilder<DB, 'assets'>) {
|
|
|
|
|
return jsonObjectFrom(eb.selectFrom('users').selectAll().whereRef('users.id', '=', 'assets.ownerId')).as('owner');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withLibrary(eb: ExpressionBuilder<DB, 'assets'>) {
|
|
|
|
|
return jsonObjectFrom(eb.selectFrom('libraries').selectAll().whereRef('libraries.id', '=', 'assets.libraryId')).as(
|
|
|
|
|
'library',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withStackedAssets<O>(qb: SelectQueryBuilder<DB, 'assets' | 'asset_stack', O>) {
|
|
|
|
|
return qb
|
|
|
|
|
.innerJoinLateral(
|
|
|
|
|
(eb: ExpressionBuilder<DB, 'assets' | 'asset_stack'>) =>
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('assets as stacked')
|
|
|
|
|
.select((eb) => eb.fn<Selectable<Assets>[]>('array_agg', [eb.table('stacked')]).as('assets'))
|
|
|
|
|
.whereRef('asset_stack.id', '=', 'stacked.stackId')
|
|
|
|
|
.whereRef('asset_stack.primaryAssetId', '!=', 'stacked.id')
|
|
|
|
|
.as('s'),
|
|
|
|
|
(join) =>
|
|
|
|
|
join.on((eb) =>
|
|
|
|
|
eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.select('s.assets');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withStack<O>(
|
|
|
|
|
qb: SelectQueryBuilder<DB, 'assets', O>,
|
|
|
|
|
{ assets, count }: { assets: boolean; count: boolean },
|
|
|
|
|
) {
|
|
|
|
|
return qb
|
|
|
|
|
.leftJoinLateral(
|
|
|
|
|
(eb) =>
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('asset_stack')
|
|
|
|
|
.selectAll('asset_stack')
|
|
|
|
|
.whereRef('assets.stackId', '=', 'asset_stack.id')
|
|
|
|
|
.$if(assets, withStackedAssets)
|
|
|
|
|
.$if(count, (qb) =>
|
|
|
|
|
// There is no `selectNoFrom` method for expression builders
|
|
|
|
|
qb.select(
|
|
|
|
|
sql`(select count(*) as "assetCount" where "asset_stack"."id" = "assets"."stackId")`.as('assetCount'),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.as('stacked_assets'),
|
|
|
|
|
(join) => join.onTrue(),
|
|
|
|
|
)
|
|
|
|
|
.select((eb) => eb.fn('to_jsonb', [eb.table('stacked_assets')]).as('stack'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withAlbums<O>(qb: SelectQueryBuilder<DB, 'assets', O>, { albumId }: { albumId?: string }) {
|
|
|
|
|
return qb
|
|
|
|
|
.select((eb) =>
|
|
|
|
|
jsonArrayFrom(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll()
|
|
|
|
|
.innerJoin('albums_assets_assets', (join) =>
|
|
|
|
|
join
|
|
|
|
|
.onRef('albums.id', '=', 'albums_assets_assets.albumsId')
|
|
|
|
|
.onRef('assets.id', '=', 'albums_assets_assets.assetsId'),
|
|
|
|
|
)
|
|
|
|
|
.whereRef('albums.id', '=', 'albums_assets_assets.albumsId')
|
|
|
|
|
.$if(!!albumId, (qb) => qb.where('albums.id', '=', asUuid(albumId!))),
|
|
|
|
|
).as('albums'),
|
|
|
|
|
)
|
|
|
|
|
.$if(!!albumId, (qb) =>
|
|
|
|
|
qb.where((eb) =>
|
|
|
|
|
eb.exists((eb) =>
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('albums_assets_assets')
|
|
|
|
|
.whereRef('albums_assets_assets.assetsId', '=', 'assets.id')
|
|
|
|
|
.where('albums_assets_assets.albumsId', '=', asUuid(albumId!)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
|
|
|
|
|
return jsonArrayFrom(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('tags')
|
|
|
|
|
.selectAll('tags')
|
|
|
|
|
.innerJoin('tag_asset', 'tags.id', 'tag_asset.tagsId')
|
|
|
|
|
.whereRef('assets.id', '=', 'tag_asset.assetsId'),
|
|
|
|
|
).as('tags');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function truncatedDate<O>(size: TimeBucketSize) {
|
|
|
|
|
return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
|
|
|
|
|
|
|
|
|
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
|
|
|
|
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
|
|
|
|
|
options.isArchived ??= options.withArchived ? undefined : false;
|
|
|
|
|
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore);
|
|
|
|
|
return hasPeople(kysely.withPlugin(joinDeduplicationPlugin), options.personIds)
|
|
|
|
|
.selectAll('assets')
|
|
|
|
|
.$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!))
|
|
|
|
|
.$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!))
|
|
|
|
|
.$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!))
|
|
|
|
|
.$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter!))
|
|
|
|
|
.$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore!))
|
|
|
|
|
.$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter!))
|
|
|
|
|
.$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore!))
|
|
|
|
|
.$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter!))
|
|
|
|
|
.$if(options.city !== undefined, (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.where('exif.city', options.city === null ? 'is' : '=', options.city!),
|
|
|
|
|
)
|
|
|
|
|
.$if(options.state !== undefined, (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.where('exif.state', options.state === null ? 'is' : '=', options.state!),
|
|
|
|
|
)
|
|
|
|
|
.$if(options.country !== undefined, (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.where('exif.country', options.country === null ? 'is' : '=', options.country!),
|
|
|
|
|
)
|
|
|
|
|
.$if(options.make !== undefined, (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.where('exif.make', options.make === null ? 'is' : '=', options.make!),
|
|
|
|
|
)
|
|
|
|
|
.$if(options.model !== undefined, (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.where('exif.model', options.model === null ? 'is' : '=', options.model!),
|
|
|
|
|
)
|
|
|
|
|
.$if(options.lensModel !== undefined, (qb) =>
|
|
|
|
|
qb
|
|
|
|
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
|
|
|
|
.where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!),
|
|
|
|
|
)
|
|
|
|
|
.$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!))
|
|
|
|
|
.$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!))
|
|
|
|
|
.$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!))
|
|
|
|
|
.$if(!!options.id, (qb) => qb.where('assets.id', '=', asUuid(options.id!)))
|
|
|
|
|
.$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!)))
|
|
|
|
|
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
|
|
|
|
.$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!))
|
|
|
|
|
.$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath!))
|
|
|
|
|
.$if(!!options.originalFileName, (qb) =>
|
|
|
|
|
qb.where(
|
|
|
|
|
sql`f_unaccent(assets."originalFileName")`,
|
|
|
|
|
'ilike',
|
|
|
|
|
sql`'%' || f_unaccent(${options.originalFileName}) || '%'`,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!))
|
|
|
|
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
|
|
|
|
.$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!))
|
|
|
|
|
.$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!))
|
|
|
|
|
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
|
|
|
|
|
.$if(options.isEncoded !== undefined, (qb) =>
|
|
|
|
|
qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
|
|
|
|
|
)
|
|
|
|
|
.$if(options.isMotion !== undefined, (qb) =>
|
|
|
|
|
qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
|
|
|
|
|
)
|
|
|
|
|
.$if(!!options.isNotInAlbum, (qb) =>
|
|
|
|
|
qb.where((eb) =>
|
|
|
|
|
eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.$if(!!options.withExif, withExifInner)
|
|
|
|
|
.$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople))
|
|
|
|
|
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
|
|
|
|
|
}
|