feat: preload textual model
This commit is contained in:
@@ -293,8 +293,8 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: '',
|
||||
darkStyle: '',
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { EndpointLifecycle } from 'src/decorators';
|
||||
import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
@@ -31,6 +32,7 @@ export class AssetController {
|
||||
|
||||
@Get('random')
|
||||
@Authenticated()
|
||||
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
|
||||
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
|
||||
return this.service.getRandom(auth, dto.count ?? 1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
import { JobService } from 'src/services/job.service';
|
||||
|
||||
@@ -15,6 +15,12 @@ export class JobController {
|
||||
return this.service.getAllJobsStatus();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ admin: true })
|
||||
createJob(@Body() dto: JobCreateDto): Promise<void> {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ admin: true })
|
||||
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MapReverseGeocodeDto,
|
||||
MapReverseGeocodeResponseDto,
|
||||
} from 'src/dtos/map.dto';
|
||||
import { MapThemeDto } from 'src/dtos/system-config.dto';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
|
||||
@@ -22,12 +21,6 @@ export class MapController {
|
||||
return this.service.getMapMarkers(auth, options);
|
||||
}
|
||||
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Get('style.json')
|
||||
getMapStyle(@Query() dto: MapThemeDto) {
|
||||
return this.service.getMapStyle(dto.theme);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Get('reverse-geocode')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import {
|
||||
MetadataSearchDto,
|
||||
PlacesResponseDto,
|
||||
RandomSearchDto,
|
||||
SearchExploreResponseDto,
|
||||
SearchPeopleDto,
|
||||
SearchPlacesDto,
|
||||
@@ -28,6 +29,13 @@ export class SearchController {
|
||||
return this.service.searchMetadata(auth, dto);
|
||||
}
|
||||
|
||||
@Post('random')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated()
|
||||
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<SearchResponseDto> {
|
||||
return this.service.searchRandom(auth, dto);
|
||||
}
|
||||
|
||||
@Post('smart')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TrashResponseDto } from 'src/dtos/trash.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
@@ -12,23 +13,23 @@ export class TrashController {
|
||||
constructor(private service: TrashService) {}
|
||||
|
||||
@Post('empty')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated({ permission: Permission.ASSET_DELETE })
|
||||
emptyTrash(@Auth() auth: AuthDto): Promise<void> {
|
||||
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
|
||||
return this.service.empty(auth);
|
||||
}
|
||||
|
||||
@Post('restore')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated({ permission: Permission.ASSET_DELETE })
|
||||
restoreTrash(@Auth() auth: AuthDto): Promise<void> {
|
||||
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
|
||||
return this.service.restore(auth);
|
||||
}
|
||||
|
||||
@Post('restore/assets')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Authenticated({ permission: Permission.ASSET_DELETE })
|
||||
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
|
||||
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
|
||||
return this.service.restoreAssets(auth, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ export class SystemConfigCore {
|
||||
}
|
||||
}
|
||||
|
||||
if (config.server.externalDomain.length > 0) {
|
||||
config.server.externalDomain = new URL(config.server.externalDomain).origin;
|
||||
}
|
||||
|
||||
if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) {
|
||||
config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty } from 'class-validator';
|
||||
import { ManualJobName } from 'src/enum';
|
||||
import { JobCommand, QueueName } from 'src/interfaces/job.interface';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
@@ -20,6 +21,12 @@ export class JobCommandDto {
|
||||
force!: boolean;
|
||||
}
|
||||
|
||||
export class JobCreateDto {
|
||||
@IsEnum(ManualJobName)
|
||||
@ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' })
|
||||
name!: ManualJobName;
|
||||
}
|
||||
|
||||
export class JobCountsDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
active!: number;
|
||||
|
||||
@@ -40,14 +40,14 @@ export class DuplicateDetectionConfig extends TaskConfig {
|
||||
|
||||
export class FacialRecognitionConfig extends ModelConfig {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Min(0.1)
|
||||
@Max(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
minScore!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Min(0.1)
|
||||
@Max(2)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
|
||||
@@ -119,7 +119,15 @@ class BaseSearchDto {
|
||||
personIds?: string[];
|
||||
}
|
||||
|
||||
export class MetadataSearchDto extends BaseSearchDto {
|
||||
export class RandomSearchDto extends BaseSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
withStacked?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
withPeople?: boolean;
|
||||
}
|
||||
|
||||
export class MetadataSearchDto extends RandomSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
id?: string;
|
||||
|
||||
@@ -133,12 +141,6 @@ export class MetadataSearchDto extends BaseSearchDto {
|
||||
@Optional()
|
||||
checksum?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
withStacked?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
withPeople?: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
|
||||
@@ -121,6 +121,8 @@ export class ServerConfigDto {
|
||||
isInitialized!: boolean;
|
||||
isOnboarded!: boolean;
|
||||
externalDomain!: string;
|
||||
mapDarkStyleUrl!: string;
|
||||
mapLightStyleUrl!: string;
|
||||
}
|
||||
|
||||
export class ServerFeaturesDto {
|
||||
|
||||
@@ -296,10 +296,12 @@ class SystemConfigMapDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsUrl()
|
||||
lightStyle!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsUrl()
|
||||
darkStyle!: string;
|
||||
}
|
||||
|
||||
|
||||
6
server/src/dtos/trash.dto.ts
Normal file
6
server/src/dtos/trash.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TrashResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
count!: number;
|
||||
}
|
||||
@@ -8,12 +8,6 @@ export class CreateProfileImageDto {
|
||||
|
||||
export class CreateProfileImageResponseDto {
|
||||
userId!: string;
|
||||
profileChangedAt!: Date;
|
||||
profileImagePath!: string;
|
||||
}
|
||||
|
||||
export function mapCreateProfileImageResponse(userId: string, profileImagePath: string): CreateProfileImageResponseDto {
|
||||
return {
|
||||
userId,
|
||||
profileImagePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export class UserResponseDto {
|
||||
profileImagePath!: string;
|
||||
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
|
||||
avatarColor!: UserAvatarColor;
|
||||
profileChangedAt!: Date;
|
||||
}
|
||||
|
||||
export class UserLicense {
|
||||
@@ -47,6 +48,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => {
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: getPreferences(entity).avatar.color,
|
||||
profileChangedAt: entity.profileChangedAt,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
@@ -70,6 +70,9 @@ export class AssetEntity {
|
||||
@Column()
|
||||
type!: AssetType;
|
||||
|
||||
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
|
||||
status!: AssetStatus;
|
||||
|
||||
@Column()
|
||||
originalPath!: string;
|
||||
|
||||
|
||||
@@ -67,4 +67,7 @@ export class UserEntity {
|
||||
|
||||
@OneToMany(() => UserMetadataEntity, (metadata) => metadata.user)
|
||||
metadata!: UserMetadataEntity[];
|
||||
|
||||
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
|
||||
profileChangedAt!: Date;
|
||||
}
|
||||
|
||||
@@ -182,7 +182,19 @@ export enum UserStatus {
|
||||
DELETED = 'deleted',
|
||||
}
|
||||
|
||||
export enum AssetStatus {
|
||||
ACTIVE = 'active',
|
||||
TRASHED = 'trashed',
|
||||
DELETED = 'deleted',
|
||||
}
|
||||
|
||||
export enum SourceType {
|
||||
MACHINE_LEARNING = 'machine-learning',
|
||||
EXIF = 'exif',
|
||||
}
|
||||
|
||||
export enum ManualJobName {
|
||||
PERSON_CLEANUP = 'person-cleanup',
|
||||
TAG_CLEANUP = 'tag-cleanup',
|
||||
USER_CLEANUP = 'user-cleanup',
|
||||
}
|
||||
|
||||
@@ -16,18 +16,15 @@ export interface AlbumInfoOptions {
|
||||
|
||||
export interface IAlbumRepository extends IBulkAsset {
|
||||
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
|
||||
getByIds(ids: string[]): Promise<AlbumEntity[]>;
|
||||
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
|
||||
removeAsset(assetId: string): Promise<void>;
|
||||
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
|
||||
getInvalidThumbnail(): Promise<string[]>;
|
||||
getOwned(ownerId: string): Promise<AlbumEntity[]>;
|
||||
getShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||
getNotShared(ownerId: string): Promise<AlbumEntity[]>;
|
||||
restoreAll(userId: string): Promise<void>;
|
||||
softDeleteAll(userId: string): Promise<void>;
|
||||
deleteAll(userId: string): Promise<void>;
|
||||
getAll(): Promise<AlbumEntity[]>;
|
||||
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { AssetFileType, AssetOrder, AssetType } from 'src/enum';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
|
||||
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
|
||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||
import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
|
||||
@@ -56,6 +56,7 @@ export interface AssetBuilderOptions {
|
||||
userIds?: string[];
|
||||
withStacked?: boolean;
|
||||
exifInfo?: boolean;
|
||||
status?: AssetStatus;
|
||||
assetType?: AssetType;
|
||||
}
|
||||
|
||||
@@ -147,8 +148,6 @@ export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffli
|
||||
export const IAssetRepository = 'IAssetRepository';
|
||||
|
||||
export interface IAssetRepository {
|
||||
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
|
||||
getUniqueOriginalPaths(userId: string): Promise<string[]>;
|
||||
create(asset: AssetCreate): Promise<AssetEntity>;
|
||||
getByIds(
|
||||
ids: string[],
|
||||
@@ -176,7 +175,6 @@ export interface IAssetRepository {
|
||||
withDeleted?: boolean,
|
||||
): Paginated<AssetEntity>;
|
||||
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
|
||||
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
|
||||
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
|
||||
@@ -188,8 +186,6 @@ export interface IAssetRepository {
|
||||
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
|
||||
update(asset: AssetUpdateOptions): Promise<void>;
|
||||
remove(asset: AssetEntity): Promise<void>;
|
||||
softDeleteAll(ids: string[]): Promise<void>;
|
||||
restoreAll(ids: string[]): Promise<void>;
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
|
||||
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
|
||||
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
|
||||
|
||||
@@ -27,6 +27,7 @@ type EmitEventMap = {
|
||||
|
||||
// asset bulk events
|
||||
'assets.trash': [{ assetIds: string[]; userId: string }];
|
||||
'assets.delete': [{ assetIds: string[]; userId: string }];
|
||||
'assets.restore': [{ assetIds: string[]; userId: string }];
|
||||
|
||||
// session events
|
||||
|
||||
@@ -60,6 +60,9 @@ export enum JobName {
|
||||
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
|
||||
STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
|
||||
|
||||
// tags
|
||||
TAG_CLEANUP = 'tag-cleanup',
|
||||
|
||||
// migration
|
||||
QUEUE_MIGRATION = 'queue-migration',
|
||||
MIGRATE_ASSET = 'migrate-asset',
|
||||
@@ -90,6 +93,8 @@ export enum JobName {
|
||||
QUEUE_SMART_SEARCH = 'queue-smart-search',
|
||||
SMART_SEARCH = 'smart-search',
|
||||
|
||||
QUEUE_TRASH_EMPTY = 'queue-trash-empty',
|
||||
|
||||
// duplicate detection
|
||||
QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection',
|
||||
DUPLICATE_DETECTION = 'duplicate-detection',
|
||||
@@ -250,6 +255,7 @@ export type JobItem =
|
||||
// Smart Search
|
||||
| { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob }
|
||||
| { name: JobName.SMART_SEARCH; data: IEntityJob }
|
||||
| { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob }
|
||||
|
||||
// Duplicate Detection
|
||||
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }
|
||||
@@ -262,6 +268,9 @@ export type JobItem =
|
||||
| { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob }
|
||||
| { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob }
|
||||
|
||||
// Tags
|
||||
| { name: JobName.TAG_CLEANUP; data?: IBaseJob }
|
||||
|
||||
// Asset Deletion
|
||||
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
|
||||
| { name: JobName.ASSET_DELETION; data: IAssetDeleteJob }
|
||||
@@ -300,7 +309,6 @@ export interface IJobRepository {
|
||||
addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void;
|
||||
addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void;
|
||||
updateCronJob(name: string, expression?: string, start?: boolean): void;
|
||||
deleteCronJob(name: string): void;
|
||||
setConcurrency(queueName: QueueName, concurrency: number): void;
|
||||
queue(item: JobItem): Promise<void>;
|
||||
queueAll(items: JobItem[]): Promise<void>;
|
||||
|
||||
@@ -53,9 +53,4 @@ export interface IMetadataRepository {
|
||||
readTags(path: string): Promise<ImmichTags>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
||||
getCountries(userIds: string[]): Promise<Array<string | null>>;
|
||||
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
|
||||
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
|
||||
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
|
||||
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import { Paginated } from 'src/utils/pagination';
|
||||
|
||||
export const ISearchRepository = 'ISearchRepository';
|
||||
@@ -61,6 +61,7 @@ export interface SearchStatusOptions {
|
||||
isVisible?: boolean;
|
||||
isNotInAlbum?: boolean;
|
||||
type?: AssetType;
|
||||
status?: AssetStatus;
|
||||
withArchived?: boolean;
|
||||
withDeleted?: boolean;
|
||||
}
|
||||
@@ -115,6 +116,7 @@ export interface SearchPeopleOptions {
|
||||
|
||||
export interface SearchOrderOptions {
|
||||
orderDirection?: 'ASC' | 'DESC';
|
||||
random?: boolean;
|
||||
}
|
||||
|
||||
export interface SearchPaginationOptions {
|
||||
@@ -181,4 +183,9 @@ export interface ISearchRepository {
|
||||
deleteAllSearchEmbeddings(): Promise<void>;
|
||||
getDimensionSize(): Promise<number>;
|
||||
setDimensionSize(dimSize: number): Promise<void>;
|
||||
getCountries(userIds: string[]): Promise<Array<string | null>>;
|
||||
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
|
||||
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
|
||||
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
|
||||
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export interface IStorageRepository {
|
||||
createZipStream(): ImmichZipStream;
|
||||
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
|
||||
readFile(filepath: string, options?: FileReadOptions<Buffer>): Promise<Buffer>;
|
||||
writeFile(filepath: string, buffer: Buffer): Promise<void>;
|
||||
createFile(filepath: string, buffer: Buffer): Promise<void>;
|
||||
createOrOverwriteFile(filepath: string, buffer: Buffer): Promise<void>;
|
||||
overwriteFile(filepath: string, buffer: Buffer): Promise<void>;
|
||||
realpath(filepath: string): Promise<string>;
|
||||
unlink(filepath: string): Promise<void>;
|
||||
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
||||
|
||||
@@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset {
|
||||
|
||||
upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
|
||||
upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
|
||||
deleteEmptyTags(): Promise<void>;
|
||||
}
|
||||
|
||||
10
server/src/interfaces/trash.interface.ts
Normal file
10
server/src/interfaces/trash.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Paginated, PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export const ITrashRepository = 'ITrashRepository';
|
||||
|
||||
export interface ITrashRepository {
|
||||
empty(userId: string): Promise<number>;
|
||||
restore(userId: string): Promise<number>;
|
||||
restoreAll(assetIds: string[]): Promise<number>;
|
||||
getDeletedIds(pagination: PaginationOptions): Paginated<string>;
|
||||
}
|
||||
8
server/src/interfaces/view.interface.ts
Normal file
8
server/src/interfaces/view.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
|
||||
export const IViewRepository = 'IViewRepository';
|
||||
|
||||
export interface IViewRepository {
|
||||
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
|
||||
getUniqueOriginalPaths(userId: string): Promise<string[]>;
|
||||
}
|
||||
@@ -18,7 +18,9 @@ async function bootstrapImmichAdmin() {
|
||||
function bootstrapWorker(name: string) {
|
||||
console.log(`Starting ${name} worker`);
|
||||
|
||||
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
|
||||
const execArgv = process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg));
|
||||
const worker =
|
||||
name === 'api' ? fork(`./dist/workers/${name}.js`, [], { execArgv }) : new Worker(`./dist/workers/${name}.js`);
|
||||
|
||||
worker.on('error', (error) => {
|
||||
console.error(`${name} worker error: ${error}`);
|
||||
|
||||
14
server/src/migrations/1726491047923-AddprofileChangedAt.ts
Normal file
14
server/src/migrations/1726491047923-AddprofileChangedAt.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddprofileChangedAt1726491047923 implements MigrationInterface {
|
||||
name = 'AddprofileChangedAt1726491047923'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD "profileChangedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profileChangedAt"`);
|
||||
}
|
||||
|
||||
}
|
||||
16
server/src/migrations/1726593009549-AddAssetStatus.ts
Normal file
16
server/src/migrations/1726593009549-AddAssetStatus.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddAssetStatus1726593009549 implements MigrationInterface {
|
||||
name = 'AddAssetStatus1726593009549'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TYPE "assets_status_enum" AS ENUM('active', 'trashed', 'deleted')`);
|
||||
await queryRunner.query(`ALTER TABLE "assets" ADD "status" "assets_status_enum" NOT NULL DEFAULT 'active'`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "status"`);
|
||||
await queryRunner.query(`DROP TYPE "assets_status_enum"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,7 +23,8 @@ SELECT
|
||||
"ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status",
|
||||
"ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt",
|
||||
"ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes",
|
||||
"ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes"
|
||||
"ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes",
|
||||
"ActivityEntity__ActivityEntity_user"."profileChangedAt" AS "ActivityEntity__ActivityEntity_user_profileChangedAt"
|
||||
FROM
|
||||
"activity" "ActivityEntity"
|
||||
LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId"
|
||||
|
||||
@@ -30,6 +30,7 @@ FROM
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
|
||||
@@ -47,6 +48,7 @@ FROM
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
|
||||
@@ -80,64 +82,6 @@ ORDER BY
|
||||
LIMIT
|
||||
1
|
||||
|
||||
-- AlbumRepository.getByIds
|
||||
SELECT
|
||||
"AlbumEntity"."id" AS "AlbumEntity_id",
|
||||
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId",
|
||||
"AlbumEntity"."albumName" AS "AlbumEntity_albumName",
|
||||
"AlbumEntity"."description" AS "AlbumEntity_description",
|
||||
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt",
|
||||
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt",
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
|
||||
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
|
||||
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
|
||||
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
|
||||
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
|
||||
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
|
||||
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
|
||||
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes"
|
||||
FROM
|
||||
"albums" "AlbumEntity"
|
||||
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
|
||||
AND (
|
||||
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL
|
||||
)
|
||||
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
|
||||
LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
|
||||
AND (
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL
|
||||
)
|
||||
WHERE
|
||||
((("AlbumEntity"."id" IN ($1))))
|
||||
AND ("AlbumEntity"."deletedAt" IS NULL)
|
||||
|
||||
-- AlbumRepository.getByAssetId
|
||||
SELECT
|
||||
"AlbumEntity"."id" AS "AlbumEntity_id",
|
||||
@@ -164,6 +108,7 @@ SELECT
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
|
||||
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
|
||||
@@ -180,7 +125,8 @@ SELECT
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes"
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt"
|
||||
FROM
|
||||
"albums" "AlbumEntity"
|
||||
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
|
||||
@@ -241,35 +187,6 @@ WHERE
|
||||
GROUP BY
|
||||
"album"."id"
|
||||
|
||||
-- AlbumRepository.getInvalidThumbnail
|
||||
SELECT
|
||||
"albums"."id" AS "albums_id"
|
||||
FROM
|
||||
"albums" "albums"
|
||||
WHERE
|
||||
(
|
||||
"albums"."albumThumbnailAssetId" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
"albums_assets_assets" "albums_assets"
|
||||
WHERE
|
||||
"albums"."id" = "albums_assets"."albumsId"
|
||||
)
|
||||
OR "albums"."albumThumbnailAssetId" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
"albums_assets_assets" "albums_assets"
|
||||
WHERE
|
||||
"albums"."id" = "albums_assets"."albumsId"
|
||||
AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"
|
||||
)
|
||||
)
|
||||
AND ("albums"."deletedAt" IS NULL)
|
||||
|
||||
-- AlbumRepository.getOwned
|
||||
SELECT
|
||||
"AlbumEntity"."id" AS "AlbumEntity_id",
|
||||
@@ -299,6 +216,7 @@ SELECT
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
|
||||
@@ -324,7 +242,8 @@ SELECT
|
||||
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
|
||||
FROM
|
||||
"albums" "AlbumEntity"
|
||||
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
|
||||
@@ -372,6 +291,7 @@ SELECT
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
|
||||
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
|
||||
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
|
||||
@@ -397,7 +317,8 @@ SELECT
|
||||
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
|
||||
FROM
|
||||
"albums" "AlbumEntity"
|
||||
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
|
||||
@@ -495,7 +416,8 @@ SELECT
|
||||
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
|
||||
FROM
|
||||
"albums" "AlbumEntity"
|
||||
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
|
||||
@@ -528,41 +450,6 @@ WHERE
|
||||
ORDER BY
|
||||
"AlbumEntity"."createdAt" DESC
|
||||
|
||||
-- AlbumRepository.getAll
|
||||
SELECT
|
||||
"AlbumEntity"."id" AS "AlbumEntity_id",
|
||||
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId",
|
||||
"AlbumEntity"."albumName" AS "AlbumEntity_albumName",
|
||||
"AlbumEntity"."description" AS "AlbumEntity_description",
|
||||
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt",
|
||||
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt",
|
||||
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
|
||||
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
|
||||
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
|
||||
"AlbumEntity"."order" AS "AlbumEntity_order",
|
||||
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
|
||||
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
|
||||
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
|
||||
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
|
||||
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
|
||||
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
|
||||
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
|
||||
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
|
||||
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
|
||||
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
|
||||
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
|
||||
FROM
|
||||
"albums" "AlbumEntity"
|
||||
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
|
||||
AND (
|
||||
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL
|
||||
)
|
||||
WHERE
|
||||
"AlbumEntity"."deletedAt" IS NULL
|
||||
|
||||
-- AlbumRepository.removeAsset
|
||||
DELETE FROM "albums_assets_assets"
|
||||
WHERE
|
||||
@@ -596,16 +483,13 @@ UPDATE "albums"
|
||||
SET
|
||||
"albumThumbnailAssetId" = (
|
||||
SELECT
|
||||
"albums_assets2"."assetsId"
|
||||
"album_assets"."assetsId"
|
||||
FROM
|
||||
"assets" "assets",
|
||||
"albums_assets_assets" "albums_assets2"
|
||||
"albums_assets_assets" "album_assets"
|
||||
INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id"
|
||||
AND "assets"."deletedAt" IS NULL
|
||||
WHERE
|
||||
(
|
||||
"albums_assets2"."assetsId" = "assets"."id"
|
||||
AND "albums_assets2"."albumsId" = "albums"."id"
|
||||
)
|
||||
AND ("assets"."deletedAt" IS NULL)
|
||||
"album_assets"."albumsId" = "albums"."id"
|
||||
ORDER BY
|
||||
"assets"."fileCreatedAt" DESC
|
||||
LIMIT
|
||||
@@ -618,17 +502,21 @@ WHERE
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
"albums_assets_assets" "albums_assets"
|
||||
"albums_assets_assets" "album_assets"
|
||||
INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id"
|
||||
AND "assets"."deletedAt" IS NULL
|
||||
WHERE
|
||||
"albums"."id" = "albums_assets"."albumsId"
|
||||
"album_assets"."albumsId" = "albums"."id"
|
||||
)
|
||||
OR "albums"."albumThumbnailAssetId" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
"albums_assets_assets" "albums_assets"
|
||||
"albums_assets_assets" "album_assets"
|
||||
INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id"
|
||||
AND "assets"."deletedAt" IS NULL
|
||||
WHERE
|
||||
"albums"."id" = "albums_assets"."albumsId"
|
||||
AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"
|
||||
"album_assets"."albumsId" = "albums"."id"
|
||||
AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId"
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ FROM
|
||||
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
|
||||
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
|
||||
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes",
|
||||
"APIKeyEntity__APIKeyEntity_user"."profileChangedAt" AS "APIKeyEntity__APIKeyEntity_user_profileChangedAt",
|
||||
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId",
|
||||
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key",
|
||||
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value"
|
||||
|
||||
@@ -8,6 +8,7 @@ SELECT
|
||||
"entity"."libraryId" AS "entity_libraryId",
|
||||
"entity"."deviceId" AS "entity_deviceId",
|
||||
"entity"."type" AS "entity_type",
|
||||
"entity"."status" AS "entity_status",
|
||||
"entity"."originalPath" AS "entity_originalPath",
|
||||
"entity"."thumbhash" AS "entity_thumbhash",
|
||||
"entity"."encodedVideoPath" AS "entity_encodedVideoPath",
|
||||
@@ -96,6 +97,7 @@ SELECT
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -130,6 +132,7 @@ SELECT
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -218,6 +221,7 @@ SELECT
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."libraryId" AS "bd93d5747511a4dad4923546c51365bf1a803774_libraryId",
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId",
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type",
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."status" AS "bd93d5747511a4dad4923546c51365bf1a803774_status",
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath",
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash",
|
||||
"bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath",
|
||||
@@ -305,6 +309,7 @@ FROM
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -402,6 +407,7 @@ SELECT
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -455,6 +461,7 @@ SELECT
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -527,6 +534,7 @@ SELECT
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -581,6 +589,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -640,6 +649,7 @@ SELECT
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."status" AS "stackedAssets_status",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
@@ -719,6 +729,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -778,6 +789,7 @@ SELECT
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."status" AS "stackedAssets_status",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
@@ -833,6 +845,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -892,6 +905,7 @@ SELECT
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."status" AS "stackedAssets_status",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
@@ -997,6 +1011,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -1072,6 +1087,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -1134,109 +1150,6 @@ WHERE
|
||||
AND "asset"."ownerId" IN ($1)
|
||||
AND "asset"."updatedAt" > $2
|
||||
|
||||
-- AssetRepository.getAssetsByOriginalPath
|
||||
SELECT
|
||||
"asset"."id" AS "asset_id",
|
||||
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||
"asset"."ownerId" AS "asset_ownerId",
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
"asset"."createdAt" AS "asset_createdAt",
|
||||
"asset"."updatedAt" AS "asset_updatedAt",
|
||||
"asset"."deletedAt" AS "asset_deletedAt",
|
||||
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||
"asset"."localDateTime" AS "asset_localDateTime",
|
||||
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||
"asset"."isFavorite" AS "asset_isFavorite",
|
||||
"asset"."isArchived" AS "asset_isArchived",
|
||||
"asset"."isExternal" AS "asset_isExternal",
|
||||
"asset"."isOffline" AS "asset_isOffline",
|
||||
"asset"."checksum" AS "asset_checksum",
|
||||
"asset"."duration" AS "asset_duration",
|
||||
"asset"."isVisible" AS "asset_isVisible",
|
||||
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||
"asset"."originalFileName" AS "asset_originalFileName",
|
||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||
"asset"."stackId" AS "asset_stackId",
|
||||
"asset"."duplicateId" AS "asset_duplicateId",
|
||||
"exifInfo"."assetId" AS "exifInfo_assetId",
|
||||
"exifInfo"."description" AS "exifInfo_description",
|
||||
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
||||
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
|
||||
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
|
||||
"exifInfo"."orientation" AS "exifInfo_orientation",
|
||||
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
|
||||
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
|
||||
"exifInfo"."timeZone" AS "exifInfo_timeZone",
|
||||
"exifInfo"."latitude" AS "exifInfo_latitude",
|
||||
"exifInfo"."longitude" AS "exifInfo_longitude",
|
||||
"exifInfo"."projectionType" AS "exifInfo_projectionType",
|
||||
"exifInfo"."city" AS "exifInfo_city",
|
||||
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
|
||||
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
|
||||
"exifInfo"."state" AS "exifInfo_state",
|
||||
"exifInfo"."country" AS "exifInfo_country",
|
||||
"exifInfo"."make" AS "exifInfo_make",
|
||||
"exifInfo"."model" AS "exifInfo_model",
|
||||
"exifInfo"."lensModel" AS "exifInfo_lensModel",
|
||||
"exifInfo"."fNumber" AS "exifInfo_fNumber",
|
||||
"exifInfo"."focalLength" AS "exifInfo_focalLength",
|
||||
"exifInfo"."iso" AS "exifInfo_iso",
|
||||
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
|
||||
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
|
||||
"exifInfo"."colorspace" AS "exifInfo_colorspace",
|
||||
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
|
||||
"exifInfo"."rating" AS "exifInfo_rating",
|
||||
"exifInfo"."fps" AS "exifInfo_fps",
|
||||
"stack"."id" AS "stack_id",
|
||||
"stack"."ownerId" AS "stack_ownerId",
|
||||
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||
"stackedAssets"."id" AS "stackedAssets_id",
|
||||
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
|
||||
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
|
||||
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
|
||||
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
|
||||
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
|
||||
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
|
||||
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
|
||||
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
|
||||
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
|
||||
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
|
||||
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
|
||||
"stackedAssets"."checksum" AS "stackedAssets_checksum",
|
||||
"stackedAssets"."duration" AS "stackedAssets_duration",
|
||||
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
|
||||
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||
"stackedAssets"."stackId" AS "stackedAssets_stackId",
|
||||
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
|
||||
FROM
|
||||
"assets" "asset"
|
||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
|
||||
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" = $1
|
||||
AND (
|
||||
"asset"."originalPath" LIKE $2
|
||||
AND "asset"."originalPath" NOT LIKE $3
|
||||
)
|
||||
ORDER BY
|
||||
regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC
|
||||
|
||||
-- AssetRepository.upsertFile
|
||||
INSERT INTO
|
||||
"asset_files" (
|
||||
|
||||
@@ -28,7 +28,8 @@ FROM
|
||||
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
|
||||
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes",
|
||||
"LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt"
|
||||
FROM
|
||||
"libraries" "LibraryEntity"
|
||||
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
|
||||
@@ -68,7 +69,8 @@ SELECT
|
||||
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
|
||||
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes",
|
||||
"LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt"
|
||||
FROM
|
||||
"libraries" "LibraryEntity"
|
||||
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
|
||||
@@ -104,7 +106,8 @@ SELECT
|
||||
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
|
||||
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
|
||||
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes",
|
||||
"LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt"
|
||||
FROM
|
||||
"libraries" "LibraryEntity"
|
||||
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- MetadataRepository.getCountries
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."country") "exif"."country" AS "country"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
|
||||
-- MetadataRepository.getStates
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."state") "exif"."state" AS "state"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."country" = $2
|
||||
|
||||
-- MetadataRepository.getCities
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."city") "exif"."city" AS "city"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."country" = $2
|
||||
AND "exif"."state" = $3
|
||||
|
||||
-- MetadataRepository.getCameraMakes
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."make") "exif"."make" AS "make"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."model" = $2
|
||||
|
||||
-- MetadataRepository.getCameraModels
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."model") "exif"."model" AS "model"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."make" = $2
|
||||
@@ -159,6 +159,7 @@ FROM
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
|
||||
@@ -260,6 +261,7 @@ FROM
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."status" AS "AssetEntity_status",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
@@ -391,6 +393,7 @@ SELECT
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
|
||||
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
|
||||
|
||||
@@ -13,6 +13,7 @@ FROM
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -43,6 +44,7 @@ FROM
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."status" AS "stackedAssets_status",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
@@ -106,6 +108,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -136,6 +139,7 @@ SELECT
|
||||
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||
"stackedAssets"."type" AS "stackedAssets_type",
|
||||
"stackedAssets"."status" AS "stackedAssets_status",
|
||||
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||
@@ -345,6 +349,7 @@ SELECT
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
@@ -401,3 +406,58 @@ FROM
|
||||
INNER JOIN cte ON asset.id = cte."assetId"
|
||||
ORDER BY
|
||||
exif.city
|
||||
|
||||
-- SearchRepository.getCountries
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."country") "exif"."country" AS "country"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
|
||||
-- SearchRepository.getStates
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."state") "exif"."state" AS "state"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."country" = $2
|
||||
|
||||
-- SearchRepository.getCities
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."city") "exif"."city" AS "city"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."country" = $2
|
||||
AND "exif"."state" = $3
|
||||
|
||||
-- SearchRepository.getCameraMakes
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."make") "exif"."make" AS "make"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."model" = $2
|
||||
|
||||
-- SearchRepository.getCameraModels
|
||||
SELECT DISTINCT
|
||||
ON ("exif"."model") "exif"."model" AS "model"
|
||||
FROM
|
||||
"exif" "exif"
|
||||
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
WHERE
|
||||
"asset"."ownerId" IN ($1)
|
||||
AND "exif"."make" = $2
|
||||
|
||||
@@ -39,6 +39,7 @@ FROM
|
||||
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
|
||||
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
|
||||
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes",
|
||||
"SessionEntity__SessionEntity_user"."profileChangedAt" AS "SessionEntity__SessionEntity_user_profileChangedAt",
|
||||
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId",
|
||||
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key",
|
||||
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value"
|
||||
|
||||
@@ -27,6 +27,7 @@ FROM
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath",
|
||||
@@ -93,6 +94,7 @@ FROM
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."libraryId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_libraryId",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."status" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_status",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash",
|
||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath",
|
||||
@@ -156,7 +158,8 @@ FROM
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt"
|
||||
FROM
|
||||
"shared_links" "SharedLinkEntity"
|
||||
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
|
||||
@@ -213,6 +216,7 @@ SELECT
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash",
|
||||
"SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath",
|
||||
@@ -257,7 +261,8 @@ SELECT
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes",
|
||||
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt"
|
||||
FROM
|
||||
"shared_links" "SharedLinkEntity"
|
||||
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
|
||||
@@ -309,7 +314,8 @@ FROM
|
||||
"SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status",
|
||||
"SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt",
|
||||
"SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes",
|
||||
"SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes"
|
||||
"SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes",
|
||||
"SharedLinkEntity__SharedLinkEntity_user"."profileChangedAt" AS "SharedLinkEntity__SharedLinkEntity_user_profileChangedAt"
|
||||
FROM
|
||||
"shared_links" "SharedLinkEntity"
|
||||
LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId"
|
||||
|
||||
@@ -15,7 +15,8 @@ SELECT
|
||||
"UserEntity"."status" AS "UserEntity_status",
|
||||
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
|
||||
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
|
||||
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
|
||||
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
|
||||
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
|
||||
FROM
|
||||
"users" "UserEntity"
|
||||
WHERE
|
||||
@@ -60,7 +61,8 @@ SELECT
|
||||
"user"."status" AS "user_status",
|
||||
"user"."updatedAt" AS "user_updatedAt",
|
||||
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes",
|
||||
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes"
|
||||
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes",
|
||||
"user"."profileChangedAt" AS "user_profileChangedAt"
|
||||
FROM
|
||||
"users" "user"
|
||||
WHERE
|
||||
@@ -82,7 +84,8 @@ SELECT
|
||||
"UserEntity"."status" AS "UserEntity_status",
|
||||
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
|
||||
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
|
||||
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
|
||||
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
|
||||
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
|
||||
FROM
|
||||
"users" "UserEntity"
|
||||
WHERE
|
||||
@@ -106,7 +109,8 @@ SELECT
|
||||
"UserEntity"."status" AS "UserEntity_status",
|
||||
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
|
||||
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
|
||||
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
|
||||
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
|
||||
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
|
||||
FROM
|
||||
"users" "UserEntity"
|
||||
WHERE
|
||||
|
||||
79
server/src/queries/view.repository.sql
Normal file
79
server/src/queries/view.repository.sql
Normal file
@@ -0,0 +1,79 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- ViewRepository.getAssetsByOriginalPath
|
||||
SELECT
|
||||
"asset"."id" AS "asset_id",
|
||||
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||
"asset"."ownerId" AS "asset_ownerId",
|
||||
"asset"."libraryId" AS "asset_libraryId",
|
||||
"asset"."deviceId" AS "asset_deviceId",
|
||||
"asset"."type" AS "asset_type",
|
||||
"asset"."status" AS "asset_status",
|
||||
"asset"."originalPath" AS "asset_originalPath",
|
||||
"asset"."thumbhash" AS "asset_thumbhash",
|
||||
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||
"asset"."createdAt" AS "asset_createdAt",
|
||||
"asset"."updatedAt" AS "asset_updatedAt",
|
||||
"asset"."deletedAt" AS "asset_deletedAt",
|
||||
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||
"asset"."localDateTime" AS "asset_localDateTime",
|
||||
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||
"asset"."isFavorite" AS "asset_isFavorite",
|
||||
"asset"."isArchived" AS "asset_isArchived",
|
||||
"asset"."isExternal" AS "asset_isExternal",
|
||||
"asset"."isOffline" AS "asset_isOffline",
|
||||
"asset"."checksum" AS "asset_checksum",
|
||||
"asset"."duration" AS "asset_duration",
|
||||
"asset"."isVisible" AS "asset_isVisible",
|
||||
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||
"asset"."originalFileName" AS "asset_originalFileName",
|
||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||
"asset"."stackId" AS "asset_stackId",
|
||||
"asset"."duplicateId" AS "asset_duplicateId",
|
||||
"exifInfo"."assetId" AS "exifInfo_assetId",
|
||||
"exifInfo"."description" AS "exifInfo_description",
|
||||
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
||||
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
|
||||
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
|
||||
"exifInfo"."orientation" AS "exifInfo_orientation",
|
||||
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
|
||||
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
|
||||
"exifInfo"."timeZone" AS "exifInfo_timeZone",
|
||||
"exifInfo"."latitude" AS "exifInfo_latitude",
|
||||
"exifInfo"."longitude" AS "exifInfo_longitude",
|
||||
"exifInfo"."projectionType" AS "exifInfo_projectionType",
|
||||
"exifInfo"."city" AS "exifInfo_city",
|
||||
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
|
||||
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
|
||||
"exifInfo"."state" AS "exifInfo_state",
|
||||
"exifInfo"."country" AS "exifInfo_country",
|
||||
"exifInfo"."make" AS "exifInfo_make",
|
||||
"exifInfo"."model" AS "exifInfo_model",
|
||||
"exifInfo"."lensModel" AS "exifInfo_lensModel",
|
||||
"exifInfo"."fNumber" AS "exifInfo_fNumber",
|
||||
"exifInfo"."focalLength" AS "exifInfo_focalLength",
|
||||
"exifInfo"."iso" AS "exifInfo_iso",
|
||||
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
|
||||
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
|
||||
"exifInfo"."colorspace" AS "exifInfo_colorspace",
|
||||
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
|
||||
"exifInfo"."rating" AS "exifInfo_rating",
|
||||
"exifInfo"."fps" AS "exifInfo_fps"
|
||||
FROM
|
||||
"assets" "asset"
|
||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||
WHERE
|
||||
(
|
||||
(
|
||||
"asset"."isVisible" = $1
|
||||
AND "asset"."isArchived" = $2
|
||||
AND "asset"."ownerId" = $3
|
||||
)
|
||||
AND (
|
||||
"asset"."originalPath" LIKE $4
|
||||
AND "asset"."originalPath" NOT LIKE $5
|
||||
)
|
||||
)
|
||||
AND ("asset"."deletedAt" IS NULL)
|
||||
ORDER BY
|
||||
regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC
|
||||
@@ -57,22 +57,6 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
return withoutDeletedUsers(album);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
@ChunkedArray()
|
||||
async getByIds(ids: string[]): Promise<AlbumEntity[]> {
|
||||
const albums = await this.repository.find({
|
||||
where: {
|
||||
id: In(ids),
|
||||
},
|
||||
relations: {
|
||||
owner: true,
|
||||
albumUsers: { user: true },
|
||||
},
|
||||
});
|
||||
|
||||
return albums.map((album) => withoutDeletedUsers(album));
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
||||
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
|
||||
const albums = await this.repository.find({
|
||||
@@ -116,34 +100,6 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the album IDs that have an invalid thumbnail, when:
|
||||
* - Thumbnail references an asset outside the album
|
||||
* - Empty album still has a thumbnail set
|
||||
*/
|
||||
@GenerateSql()
|
||||
async getInvalidThumbnail(): Promise<string[]> {
|
||||
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||
const albumHasAssets = this.dataSource
|
||||
.createQueryBuilder()
|
||||
.select('1')
|
||||
.from('albums_assets_assets', 'albums_assets')
|
||||
.where('"albums"."id" = "albums_assets"."albumsId"');
|
||||
|
||||
const albumContainsThumbnail = albumHasAssets
|
||||
.clone()
|
||||
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
|
||||
|
||||
const albums = await this.repository
|
||||
.createQueryBuilder('albums')
|
||||
.select('albums.id')
|
||||
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
|
||||
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`)
|
||||
.getMany();
|
||||
|
||||
return albums.map((album) => album.id);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getOwned(ownerId: string): Promise<AlbumEntity[]> {
|
||||
const albums = await this.repository.find({
|
||||
@@ -199,15 +155,6 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
await this.repository.delete({ ownerId: userId });
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAll(): Promise<AlbumEntity[]> {
|
||||
return this.repository.find({
|
||||
relations: {
|
||||
owner: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async removeAsset(assetId: string): Promise<void> {
|
||||
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||
@@ -330,32 +277,26 @@ export class AlbumRepository implements IAlbumRepository {
|
||||
@GenerateSql()
|
||||
async updateThumbnails(): Promise<number | undefined> {
|
||||
// Subquery for getting a new thumbnail.
|
||||
const newThumbnail = this.assetRepository
|
||||
.createQueryBuilder('assets')
|
||||
.select('albums_assets2.assetsId')
|
||||
.addFrom('albums_assets_assets', 'albums_assets2')
|
||||
.where('albums_assets2.assetsId = assets.id')
|
||||
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
|
||||
.orderBy('assets.fileCreatedAt', 'DESC')
|
||||
.limit(1);
|
||||
|
||||
// Using dataSource, because there is no direct access to albums_assets_assets.
|
||||
const albumHasAssets = this.dataSource
|
||||
.createQueryBuilder()
|
||||
.select('1')
|
||||
.from('albums_assets_assets', 'albums_assets')
|
||||
.where('"albums"."id" = "albums_assets"."albumsId"');
|
||||
const builder = this.dataSource
|
||||
.createQueryBuilder('albums_assets_assets', 'album_assets')
|
||||
.innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"')
|
||||
.where('"album_assets"."albumsId" = "albums"."id"');
|
||||
|
||||
const albumContainsThumbnail = albumHasAssets
|
||||
const newThumbnail = builder
|
||||
.clone()
|
||||
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
|
||||
.select('"album_assets"."assetsId"')
|
||||
.orderBy('"assets"."fileCreatedAt"', 'DESC')
|
||||
.limit(1);
|
||||
const hasAssets = builder.clone().select('1');
|
||||
const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"');
|
||||
|
||||
const updateAlbums = this.repository
|
||||
.createQueryBuilder('albums')
|
||||
.update(AlbumEntity)
|
||||
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
|
||||
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
|
||||
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
|
||||
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`)
|
||||
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`);
|
||||
|
||||
const result = await updateAlbums.execute();
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
||||
import { AssetFileType, AssetOrder, AssetType } from 'src/enum';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
|
||||
import {
|
||||
AssetBuilderOptions,
|
||||
AssetCreate,
|
||||
@@ -295,16 +295,6 @@ export class AssetRepository implements IAssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@Chunked()
|
||||
async softDeleteAll(ids: string[]): Promise<void> {
|
||||
await this.repository.softDelete({ id: In(ids) });
|
||||
}
|
||||
|
||||
@Chunked()
|
||||
async restoreAll(ids: string[]): Promise<void> {
|
||||
await this.repository.restore({ id: In(ids) });
|
||||
}
|
||||
|
||||
async update(asset: AssetUpdateOptions): Promise<void> {
|
||||
await this.repository.update(asset.id, asset);
|
||||
}
|
||||
@@ -571,13 +561,6 @@ export class AssetRepository implements IAssetRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: { albums: { id: albumId } },
|
||||
order: { fileCreatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: { albums: { id: albumId } },
|
||||
@@ -604,7 +587,10 @@ export class AssetRepository implements IAssetRepository {
|
||||
}
|
||||
|
||||
if (isTrashed !== undefined) {
|
||||
builder.withDeleted().andWhere(`asset.deletedAt is not null`);
|
||||
builder
|
||||
.withDeleted()
|
||||
.andWhere(`asset.deletedAt is not null`)
|
||||
.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
|
||||
}
|
||||
|
||||
const items = await builder.getRawMany();
|
||||
@@ -762,6 +748,10 @@ export class AssetRepository implements IAssetRepository {
|
||||
|
||||
if (options.isTrashed !== undefined) {
|
||||
builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
|
||||
|
||||
if (options.isTrashed) {
|
||||
builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isDuplicate !== undefined) {
|
||||
@@ -836,50 +826,6 @@ export class AssetRepository implements IAssetRepository {
|
||||
return builder.getMany();
|
||||
}
|
||||
|
||||
async getUniqueOriginalPaths(userId: string): Promise<string[]> {
|
||||
const builder = this.getBuilder({
|
||||
userIds: [userId],
|
||||
exifInfo: false,
|
||||
withStacked: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
});
|
||||
|
||||
const results = await builder
|
||||
.select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
|
||||
.getRawMany();
|
||||
|
||||
return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
|
||||
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
|
||||
|
||||
const builder = this.getBuilder({
|
||||
userIds: [userId],
|
||||
exifInfo: true,
|
||||
withStacked: false,
|
||||
isArchived: false,
|
||||
isTrashed: false,
|
||||
});
|
||||
|
||||
const assets = await builder
|
||||
.where('asset.ownerId = :userId', { userId })
|
||||
.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
|
||||
'asset.originalPath NOT LIKE :notLikePath',
|
||||
{ notLikePath: `%${normalizedPath}/%/%` },
|
||||
);
|
||||
}),
|
||||
)
|
||||
.orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
|
||||
.getMany();
|
||||
|
||||
return assets;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
|
||||
async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
|
||||
await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] });
|
||||
|
||||
@@ -29,7 +29,9 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { IViewRepository } from 'src/interfaces/view.interface';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
@@ -61,7 +63,9 @@ import { StackRepository } from 'src/repositories/stack.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
|
||||
export const repositories = [
|
||||
{ provide: IAccessRepository, useClass: AccessRepository },
|
||||
@@ -95,5 +99,7 @@ export const repositories = [
|
||||
{ provide: IStorageRepository, useClass: StorageRepository },
|
||||
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
|
||||
{ provide: ITagRepository, useClass: TagRepository },
|
||||
{ provide: ITrashRepository, useClass: TrashRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
{ provide: IViewRepository, useClass: ViewRepository },
|
||||
];
|
||||
|
||||
@@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
[JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION,
|
||||
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
|
||||
|
||||
// tags
|
||||
[JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK,
|
||||
|
||||
// metadata
|
||||
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
|
||||
@@ -92,6 +95,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
|
||||
// Version check
|
||||
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
|
||||
|
||||
// Trash
|
||||
[JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK,
|
||||
};
|
||||
|
||||
@Instrumentation()
|
||||
@@ -150,10 +156,6 @@ export class JobRepository implements IJobRepository {
|
||||
}
|
||||
}
|
||||
|
||||
deleteCronJob(name: string): void {
|
||||
this.schedulerReqistry.deleteCronJob(name);
|
||||
}
|
||||
|
||||
setConcurrency(queueName: QueueName, concurrency: number) {
|
||||
const worker = this.workers[queueName];
|
||||
if (!worker) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||
import geotz from 'geo-tz';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
||||
@@ -54,91 +53,4 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
async getCountries(userIds: string[]): Promise<string[]> {
|
||||
const results = await this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.country', 'country')
|
||||
.distinctOn(['exif.country'])
|
||||
.getRawMany<{ country: string }>();
|
||||
|
||||
return results.map(({ country }) => country).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
||||
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.state', 'state')
|
||||
.distinctOn(['exif.state']);
|
||||
|
||||
if (country) {
|
||||
query.andWhere('exif.country = :country', { country });
|
||||
}
|
||||
|
||||
const result = await query.getRawMany<{ state: string }>();
|
||||
|
||||
return result.map(({ state }) => state).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
|
||||
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.city', 'city')
|
||||
.distinctOn(['exif.city']);
|
||||
|
||||
if (country) {
|
||||
query.andWhere('exif.country = :country', { country });
|
||||
}
|
||||
|
||||
if (state) {
|
||||
query.andWhere('exif.state = :state', { state });
|
||||
}
|
||||
|
||||
const results = await query.getRawMany<{ city: string }>();
|
||||
|
||||
return results.map(({ city }) => city).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
||||
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.make', 'make')
|
||||
.distinctOn(['exif.make']);
|
||||
|
||||
if (model) {
|
||||
query.andWhere('exif.model = :model', { model });
|
||||
}
|
||||
|
||||
const results = await query.getRawMany<{ make: string }>();
|
||||
return results.map(({ make }) => make).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
||||
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.model', 'model')
|
||||
.distinctOn(['exif.model']);
|
||||
|
||||
if (make) {
|
||||
query.andWhere('exif.make = :make', { make });
|
||||
}
|
||||
|
||||
const results = await query.getRawMany<{ model: string }>();
|
||||
return results.map(({ model }) => model).filter((item) => item !== '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getVectorExtension } from 'src/database.config';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
||||
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
||||
@@ -35,6 +36,7 @@ export class SearchRepository implements ISearchRepository {
|
||||
constructor(
|
||||
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
||||
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
||||
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
|
||||
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||
@@ -71,8 +73,13 @@ export class SearchRepository implements ISearchRepository {
|
||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
||||
let builder = this.assetRepository.createQueryBuilder('asset');
|
||||
builder = searchAssetBuilder(builder, options);
|
||||
|
||||
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
|
||||
|
||||
if (options.random) {
|
||||
// TODO replace with complicated SQL magic after kysely migration
|
||||
builder.addSelect('RANDOM() as r').orderBy('r');
|
||||
}
|
||||
|
||||
return paginatedBuilder<AssetEntity>(builder, {
|
||||
mode: PaginationMode.SKIP_TAKE,
|
||||
skip: (pagination.page - 1) * pagination.size,
|
||||
@@ -322,6 +329,93 @@ export class SearchRepository implements ISearchRepository {
|
||||
return this.smartSearchRepository.clear();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
async getCountries(userIds: string[]): Promise<string[]> {
|
||||
const results = await this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.country', 'country')
|
||||
.distinctOn(['exif.country'])
|
||||
.getRawMany<{ country: string }>();
|
||||
|
||||
return results.map(({ country }) => country).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
||||
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.state', 'state')
|
||||
.distinctOn(['exif.state']);
|
||||
|
||||
if (country) {
|
||||
query.andWhere('exif.country = :country', { country });
|
||||
}
|
||||
|
||||
const result = await query.getRawMany<{ state: string }>();
|
||||
|
||||
return result.map(({ state }) => state).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
|
||||
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.city', 'city')
|
||||
.distinctOn(['exif.city']);
|
||||
|
||||
if (country) {
|
||||
query.andWhere('exif.country = :country', { country });
|
||||
}
|
||||
|
||||
if (state) {
|
||||
query.andWhere('exif.state = :state', { state });
|
||||
}
|
||||
|
||||
const results = await query.getRawMany<{ city: string }>();
|
||||
|
||||
return results.map(({ city }) => city).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
||||
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.make', 'make')
|
||||
.distinctOn(['exif.make']);
|
||||
|
||||
if (model) {
|
||||
query.andWhere('exif.model = :model', { model });
|
||||
}
|
||||
|
||||
const results = await query.getRawMany<{ make: string }>();
|
||||
return results.map(({ make }) => make).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
|
||||
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
|
||||
const query = this.exifRepository
|
||||
.createQueryBuilder('exif')
|
||||
.leftJoin('exif.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )', { userIds })
|
||||
.select('exif.model', 'model')
|
||||
.distinctOn(['exif.model']);
|
||||
|
||||
if (make) {
|
||||
query.andWhere('exif.make = :make', { make });
|
||||
}
|
||||
|
||||
const results = await query.getRawMany<{ model: string }>();
|
||||
return results.map(({ model }) => model).filter((item) => item !== '');
|
||||
}
|
||||
|
||||
private getRuntimeConfig(numResults?: number): string {
|
||||
if (getVectorExtension() === DatabaseExtension.VECTOR) {
|
||||
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall
|
||||
|
||||
@@ -40,8 +40,16 @@ export class StorageRepository implements IStorageRepository {
|
||||
return fs.stat(filepath);
|
||||
}
|
||||
|
||||
writeFile(filepath: string, buffer: Buffer) {
|
||||
return fs.writeFile(filepath, buffer);
|
||||
createFile(filepath: string, buffer: Buffer) {
|
||||
return fs.writeFile(filepath, buffer, { flag: 'wx' });
|
||||
}
|
||||
|
||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||
return fs.writeFile(filepath, buffer, { flag: 'w' });
|
||||
}
|
||||
|
||||
overwriteFile(filepath: string, buffer: Buffer) {
|
||||
return fs.writeFile(filepath, buffer, { flag: 'r+' });
|
||||
}
|
||||
|
||||
rename(source: string, target: string) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
import { DataSource, In, Repository, TreeRepository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
@@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository {
|
||||
constructor(
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
|
||||
) {}
|
||||
@InjectRepository(TagEntity) private tree: TreeRepository<TagEntity>,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(TagRepository.name);
|
||||
}
|
||||
|
||||
get(id: string): Promise<TagEntity | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
@@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEmptyTags() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const ids = new Set<string>();
|
||||
const tags = await manager.find(TagEntity);
|
||||
for (const tag of tags) {
|
||||
const count = await manager
|
||||
.createQueryBuilder('assets', 'asset')
|
||||
.innerJoin(
|
||||
'asset.tags',
|
||||
'asset_tags',
|
||||
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
|
||||
{ tagId: tag.id },
|
||||
)
|
||||
.getCount();
|
||||
|
||||
if (count === 0) {
|
||||
this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`);
|
||||
ids.add(tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.size > 0) {
|
||||
await manager.delete(TagEntity, { id: In([...ids]) });
|
||||
this.logger.log(`Deleted ${ids.size} empty tags`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
|
||||
const { id } = await this.repository.save(partial);
|
||||
return this.repository.findOneOrFail({ where: { id } });
|
||||
|
||||
49
server/src/repositories/trash.repository.ts
Normal file
49
server/src/repositories/trash.repository.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetStatus } from 'src/enum';
|
||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||
import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
export class TrashRepository implements ITrashRepository {
|
||||
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
|
||||
|
||||
async getDeletedIds(pagination: PaginationOptions): Paginated<string> {
|
||||
const { hasNextPage, items } = await paginatedBuilder(
|
||||
this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.select('asset.id')
|
||||
.where({ status: AssetStatus.DELETED })
|
||||
.withDeleted(),
|
||||
pagination,
|
||||
);
|
||||
|
||||
return {
|
||||
hasNextPage,
|
||||
items: items.map((asset) => asset.id),
|
||||
};
|
||||
}
|
||||
|
||||
async restore(userId: string): Promise<number> {
|
||||
const result = await this.assetRepository.update(
|
||||
{ ownerId: userId, deletedAt: Not(IsNull()) },
|
||||
{ status: AssetStatus.ACTIVE, deletedAt: null },
|
||||
);
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
async empty(userId: string): Promise<number> {
|
||||
const result = await this.assetRepository.update(
|
||||
{ ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED },
|
||||
{ status: AssetStatus.DELETED },
|
||||
);
|
||||
|
||||
return result.affected || 0;
|
||||
}
|
||||
|
||||
async restoreAll(ids: string[]): Promise<number> {
|
||||
const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null });
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
48
server/src/repositories/view-repository.ts
Normal file
48
server/src/repositories/view-repository.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { IViewRepository } from 'src/interfaces/view.interface';
|
||||
import { Brackets, Repository } from 'typeorm';
|
||||
|
||||
export class ViewRepository implements IViewRepository {
|
||||
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
|
||||
|
||||
async getUniqueOriginalPaths(userId: string): Promise<string[]> {
|
||||
const results = await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.where({
|
||||
isVisible: true,
|
||||
isArchived: false,
|
||||
ownerId: userId,
|
||||
})
|
||||
.select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
|
||||
.getRawMany();
|
||||
|
||||
return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
|
||||
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
|
||||
const assets = await this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.where({
|
||||
isVisible: true,
|
||||
isArchived: false,
|
||||
ownerId: userId,
|
||||
})
|
||||
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
|
||||
.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
|
||||
'asset.originalPath NOT LIKE :notLikePath',
|
||||
{ notLikePath: `%${normalizedPath}/%/%` },
|
||||
);
|
||||
}),
|
||||
)
|
||||
.orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
|
||||
.getMany();
|
||||
|
||||
return assets;
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,6 @@ describe(AlbumService.name, () => {
|
||||
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
expect(result).toHaveLength(2);
|
||||
@@ -85,7 +84,6 @@ describe(AlbumService.name, () => {
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
|
||||
expect(result).toHaveLength(1);
|
||||
@@ -98,7 +96,6 @@ describe(AlbumService.name, () => {
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: true });
|
||||
expect(result).toHaveLength(1);
|
||||
@@ -111,7 +108,6 @@ describe(AlbumService.name, () => {
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, { shared: false });
|
||||
expect(result).toHaveLength(1);
|
||||
@@ -130,7 +126,6 @@ describe(AlbumService.name, () => {
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
|
||||
@@ -139,48 +134,6 @@ describe(AlbumService.name, () => {
|
||||
expect(albumMock.getOwned).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('updates the album thumbnail by listing all albums', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.oneAssetInvalidThumbnail.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
|
||||
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
|
||||
assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('removes the thumbnail for an empty album', async () => {
|
||||
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
|
||||
albumMock.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: albumStub.emptyWithInvalidThumbnail.id,
|
||||
assetCount: 1,
|
||||
startDate: new Date('1970-01-01'),
|
||||
endDate: new Date('1970-01-01'),
|
||||
},
|
||||
]);
|
||||
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
|
||||
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||
assetMock.getFirstAssetForAlbumId.mockResolvedValue(null);
|
||||
|
||||
const result = await sut.getAll(authStub.admin, {});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
|
||||
expect(albumMock.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates album', async () => {
|
||||
albumMock.create.mockResolvedValue(albumStub.empty);
|
||||
|
||||
@@ -52,11 +52,7 @@ export class AlbumService {
|
||||
}
|
||||
|
||||
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
|
||||
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
|
||||
for (const albumId of invalidAlbumIds) {
|
||||
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
|
||||
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
|
||||
}
|
||||
await this.albumRepository.updateThumbnails();
|
||||
|
||||
let albums: AlbumEntity[];
|
||||
if (assetId) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos
|
||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
@@ -478,7 +478,10 @@ describe(AssetMediaService.name, () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]);
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
@@ -506,7 +509,10 @@ describe(AssetMediaService.name, () => {
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
@@ -532,7 +538,10 @@ describe(AssetMediaService.name, () => {
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
@@ -561,7 +570,7 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.updateAll).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: [updatedFile.originalPath, undefined] },
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetType, Permission } from 'src/enum';
|
||||
import { AssetStatus, AssetType, Permission } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
@@ -193,7 +193,7 @@ export class AssetMediaService {
|
||||
// but the local variable holds the original file data paths.
|
||||
const copiedPhoto = await this.createCopy(asset);
|
||||
// and immediate trash it
|
||||
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
|
||||
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.TRASHED });
|
||||
await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id });
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
@@ -269,10 +269,10 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
|
||||
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION, data: { id: 'asset1', deleteOnDisk: true } },
|
||||
{ name: JobName.ASSET_DELETION, data: { id: 'asset2', deleteOnDisk: true } },
|
||||
]);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', {
|
||||
assetIds: ['asset1', 'asset2'],
|
||||
userId: 'user-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should soft delete a batch of assets', async () => {
|
||||
@@ -280,7 +280,10 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']);
|
||||
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.TRASHED,
|
||||
});
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryLaneDto } from 'src/dtos/search.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AssetStatus, Permission } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
@@ -302,18 +302,11 @@ export class AssetService {
|
||||
const { ids, force } = dto;
|
||||
|
||||
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
|
||||
|
||||
if (force) {
|
||||
await this.jobRepository.queueAll(
|
||||
ids.map((id) => ({
|
||||
name: JobName.ASSET_DELETION,
|
||||
data: { id, deleteOnDisk: true },
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
await this.assetRepository.softDeleteAll(ids);
|
||||
await this.eventRepository.emit('assets.trash', { assetIds: ids, userId: auth.user.id });
|
||||
}
|
||||
await this.assetRepository.updateAll(ids, {
|
||||
deletedAt: new Date(),
|
||||
status: force ? AssetStatus.DELETED : AssetStatus.TRASHED,
|
||||
});
|
||||
await this.eventRepository.emit(force ? 'assets.delete' : 'assets.trash', { assetIds: ids, userId: auth.user.id });
|
||||
}
|
||||
|
||||
async run(auth: AuthDto, dto: AssetJobsDto) {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { snakeCase } from 'lodash';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||
import { AssetType } from 'src/enum';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||
import { AssetType, ManualJobName } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
@@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
|
||||
const asJobItem = (dto: JobCreateDto): JobItem => {
|
||||
switch (dto.name) {
|
||||
case ManualJobName.TAG_CLEANUP: {
|
||||
return { name: JobName.TAG_CLEANUP };
|
||||
}
|
||||
|
||||
case ManualJobName.PERSON_CLEANUP: {
|
||||
return { name: JobName.PERSON_CLEANUP };
|
||||
}
|
||||
|
||||
case ManualJobName.USER_CLEANUP: {
|
||||
return { name: JobName.USER_DELETE_CHECK };
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new BadRequestException('Invalid job name');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JobService {
|
||||
private configCore: SystemConfigCore;
|
||||
@@ -39,6 +59,10 @@ export class JobService {
|
||||
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
|
||||
}
|
||||
|
||||
async create(dto: JobCreateDto): Promise<void> {
|
||||
await this.jobRepository.queue(asJobItem(dto));
|
||||
}
|
||||
|
||||
async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> {
|
||||
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
|
||||
|
||||
@@ -162,11 +186,16 @@ export class JobService {
|
||||
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
|
||||
const { name, data } = item;
|
||||
|
||||
const handler = jobHandlers[name];
|
||||
if (!handler) {
|
||||
this.logger.warn(`Skipping unknown job: "${name}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
|
||||
this.metricRepository.jobs.addToGauge(queueMetric, 1);
|
||||
|
||||
try {
|
||||
const handler = jobHandlers[name];
|
||||
const status = await handler(data);
|
||||
const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`;
|
||||
this.metricRepository.jobs.addToCounter(jobMetric, 1);
|
||||
|
||||
@@ -43,17 +43,6 @@ export class MapService {
|
||||
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
|
||||
}
|
||||
|
||||
async getMapStyle(theme: 'light' | 'dark') {
|
||||
const { map } = await this.configCore.getConfig({ withCache: false });
|
||||
const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle;
|
||||
|
||||
if (styleUrl) {
|
||||
return this.mapRepository.fetchStyle(styleUrl);
|
||||
}
|
||||
|
||||
return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`));
|
||||
}
|
||||
|
||||
async reverseGeocode(dto: MapReverseGeocodeDto) {
|
||||
const { lat: latitude, lon: longitude } = dto;
|
||||
// eventually this should probably return an array of results
|
||||
|
||||
@@ -511,7 +511,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
|
||||
expect(storageMock.writeFile).not.toHaveBeenCalled();
|
||||
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith(
|
||||
@@ -581,7 +581,7 @@ describe(MetadataService.name, () => {
|
||||
type: AssetType.VIDEO,
|
||||
});
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
|
||||
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
|
||||
id: assetStub.livePhotoWithOriginalFileName.id,
|
||||
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
|
||||
@@ -624,7 +624,7 @@ describe(MetadataService.name, () => {
|
||||
type: AssetType.VIDEO,
|
||||
});
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
|
||||
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
|
||||
id: assetStub.livePhotoWithOriginalFileName.id,
|
||||
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
|
||||
@@ -668,7 +668,7 @@ describe(MetadataService.name, () => {
|
||||
type: AssetType.VIDEO,
|
||||
});
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
|
||||
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
|
||||
id: assetStub.livePhotoWithOriginalFileName.id,
|
||||
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
|
||||
@@ -716,7 +716,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
expect(assetMock.create).toHaveBeenCalledTimes(0);
|
||||
expect(storageMock.writeFile).toHaveBeenCalledTimes(0);
|
||||
expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0);
|
||||
// The still asset gets saved by handleMetadataExtraction, but not the video
|
||||
expect(assetMock.update).toHaveBeenCalledTimes(1);
|
||||
expect(jobMock.queue).toHaveBeenCalledTimes(0);
|
||||
@@ -1107,6 +1107,30 @@ describe(MetadataService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid rating value', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.readTags.mockResolvedValue({ Rating: 6 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle valid rating value', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.readTags.mockResolvedValue({ Rating: 5 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(assetMock.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: 5,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSidecar', () => {
|
||||
|
||||
@@ -83,6 +83,18 @@ const validate = <T>(value: T): NonNullable<T> | null => {
|
||||
return value ?? null;
|
||||
};
|
||||
|
||||
const validateRange = (value: number | undefined, min: number, max: number): NonNullable<number> | null => {
|
||||
// reutilizes the validate function
|
||||
const val = validate(value);
|
||||
|
||||
// check if the value is within the range
|
||||
if (val == null || val < min || val > max) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return val;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
private storageCore: StorageCore;
|
||||
@@ -261,7 +273,7 @@ export class MetadataService {
|
||||
// comments
|
||||
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
||||
profileDescription: exifTags.ProfileDescription || null,
|
||||
rating: exifTags.Rating ?? null,
|
||||
rating: validateRange(exifTags.Rating, 0, 5),
|
||||
|
||||
// grouping
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
@@ -529,7 +541,7 @@ export class MetadataService {
|
||||
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath);
|
||||
if (!existsOnDisk) {
|
||||
this.storageCore.ensureFolders(motionAsset.originalPath);
|
||||
await this.storageRepository.writeFile(motionAsset.originalPath, video);
|
||||
await this.storageRepository.createFile(motionAsset.originalPath, video);
|
||||
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`);
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { SessionService } from 'src/services/session.service';
|
||||
import { SmartInfoService } from 'src/services/smart-info.service';
|
||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||
import { StorageService } from 'src/services/storage.service';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { otelShutdown } from 'src/utils/instrumentation';
|
||||
@@ -34,6 +36,8 @@ export class MicroservicesService {
|
||||
private sessionService: SessionService,
|
||||
private storageTemplateService: StorageTemplateService,
|
||||
private storageService: StorageService,
|
||||
private tagService: TagService,
|
||||
private trashService: TrashService,
|
||||
private userService: UserService,
|
||||
private duplicateService: DuplicateService,
|
||||
private versionService: VersionService,
|
||||
@@ -93,7 +97,9 @@ export class MicroservicesService {
|
||||
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
|
||||
[JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data),
|
||||
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
|
||||
[JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(),
|
||||
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
|
||||
[JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
@@ -15,7 +14,6 @@ import { personStub } from 'test/fixtures/person.stub';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
|
||||
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
||||
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
|
||||
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
|
||||
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
|
||||
@@ -32,7 +30,6 @@ describe(SearchService.name, () => {
|
||||
let personMock: Mocked<IPersonRepository>;
|
||||
let searchMock: Mocked<ISearchRepository>;
|
||||
let partnerMock: Mocked<IPartnerRepository>;
|
||||
let metadataMock: Mocked<IMetadataRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -42,19 +39,9 @@ describe(SearchService.name, () => {
|
||||
personMock = newPersonRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
partnerMock = newPartnerRepositoryMock();
|
||||
metadataMock = newMetadataRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
|
||||
sut = new SearchService(
|
||||
systemMock,
|
||||
machineMock,
|
||||
personMock,
|
||||
searchMock,
|
||||
assetMock,
|
||||
partnerMock,
|
||||
metadataMock,
|
||||
loggerMock,
|
||||
);
|
||||
sut = new SearchService(systemMock, machineMock, personMock, searchMock, assetMock, partnerMock, loggerMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -99,19 +86,19 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('getSearchSuggestions', () => {
|
||||
it('should return search suggestions (including null)', async () => {
|
||||
metadataMock.getCountries.mockResolvedValue(['USA', null]);
|
||||
searchMock.getCountries.mockResolvedValue(['USA', null]);
|
||||
await expect(
|
||||
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
|
||||
).resolves.toEqual(['USA', null]);
|
||||
expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
|
||||
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
|
||||
});
|
||||
|
||||
it('should return search suggestions (without null)', async () => {
|
||||
metadataMock.getCountries.mockResolvedValue(['USA', null]);
|
||||
searchMock.getCountries.mockResolvedValue(['USA', null]);
|
||||
await expect(
|
||||
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
|
||||
).resolves.toEqual(['USA']);
|
||||
expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
|
||||
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import {
|
||||
MetadataSearchDto,
|
||||
PlacesResponseDto,
|
||||
RandomSearchDto,
|
||||
SearchPeopleDto,
|
||||
SearchPlacesDto,
|
||||
SearchResponseDto,
|
||||
@@ -19,7 +20,6 @@ import { AssetOrder } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface';
|
||||
@@ -38,7 +38,6 @@ export class SearchService {
|
||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(SearchService.name);
|
||||
@@ -95,6 +94,22 @@ export class SearchService {
|
||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
||||
}
|
||||
|
||||
async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<SearchResponseDto> {
|
||||
const userIds = await this.getUserIdsToSearch(auth);
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 250;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||
{ page, size },
|
||||
{
|
||||
...dto,
|
||||
userIds,
|
||||
random: true,
|
||||
},
|
||||
);
|
||||
|
||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
||||
}
|
||||
|
||||
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
@@ -129,19 +144,19 @@ export class SearchService {
|
||||
private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) {
|
||||
switch (dto.type) {
|
||||
case SearchSuggestionType.COUNTRY: {
|
||||
return this.metadataRepository.getCountries(userIds);
|
||||
return this.searchRepository.getCountries(userIds);
|
||||
}
|
||||
case SearchSuggestionType.STATE: {
|
||||
return this.metadataRepository.getStates(userIds, dto.country);
|
||||
return this.searchRepository.getStates(userIds, dto.country);
|
||||
}
|
||||
case SearchSuggestionType.CITY: {
|
||||
return this.metadataRepository.getCities(userIds, dto.country, dto.state);
|
||||
return this.searchRepository.getCities(userIds, dto.country, dto.state);
|
||||
}
|
||||
case SearchSuggestionType.CAMERA_MAKE: {
|
||||
return this.metadataRepository.getCameraMakes(userIds, dto.model);
|
||||
return this.searchRepository.getCameraMakes(userIds, dto.model);
|
||||
}
|
||||
case SearchSuggestionType.CAMERA_MODEL: {
|
||||
return this.metadataRepository.getCameraModels(userIds, dto.make);
|
||||
return this.searchRepository.getCameraModels(userIds, dto.make);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
|
||||
@@ -186,6 +186,8 @@ describe(ServerService.name, () => {
|
||||
isInitialized: undefined,
|
||||
isOnboarded: false,
|
||||
externalDomain: '',
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
});
|
||||
expect(systemMock.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -129,6 +129,8 @@ export class ServerService {
|
||||
isInitialized,
|
||||
isOnboarded: onboarding?.isOnboarded || false,
|
||||
externalDomain: config.server.externalDomain,
|
||||
mapDarkStyleUrl: config.map.darkStyle,
|
||||
mapLightStyleUrl: config.map.lightStyle,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ describe(StorageService.name, () => {
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile');
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer));
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer));
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer));
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer));
|
||||
expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer));
|
||||
});
|
||||
|
||||
it('should throw an error if .immich is missing', async () => {
|
||||
@@ -49,13 +54,13 @@ describe(StorageService.name, () => {
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
|
||||
|
||||
expect(storageMock.writeFile).not.toHaveBeenCalled();
|
||||
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
|
||||
expect(systemMock.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if .immich is present but read-only', async () => {
|
||||
systemMock.get.mockResolvedValue({ mountFiles: true });
|
||||
storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
|
||||
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
|
||||
|
||||
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
|
||||
|
||||
|
||||
@@ -25,14 +25,15 @@ export class StorageService {
|
||||
async onBootstrap() {
|
||||
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
|
||||
const enabled = flags.mountFiles ?? false;
|
||||
|
||||
this.logger.log('Verifying system mount folder checks');
|
||||
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
|
||||
|
||||
// check each folder exists and is writable
|
||||
for (const folder of Object.values(StorageFolder)) {
|
||||
if (!flags.mountFiles) {
|
||||
if (!enabled) {
|
||||
this.logger.log(`Writing initial mount file for the ${folder} folder`);
|
||||
await this.verifyWriteAccess(folder);
|
||||
await this.createMountFile(folder);
|
||||
}
|
||||
|
||||
await this.verifyReadAccess(folder);
|
||||
@@ -81,17 +82,30 @@ export class StorageService {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyWriteAccess(folder: StorageFolder) {
|
||||
private async createMountFile(folder: StorageFolder) {
|
||||
const { folderPath, filePath } = this.getMountFilePaths(folder);
|
||||
try {
|
||||
this.storageRepository.mkdirSync(folderPath);
|
||||
await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`));
|
||||
await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create ${filePath}: ${error}`);
|
||||
this.logger.error(
|
||||
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
|
||||
);
|
||||
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyWriteAccess(folder: StorageFolder) {
|
||||
const { filePath } = this.getMountFilePaths(folder);
|
||||
try {
|
||||
await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write ${filePath}: ${error}`);
|
||||
this.logger.error(
|
||||
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
|
||||
);
|
||||
throw new ImmichStartupError(`Failed to validate folder mount (write to "<MEDIA_LOCATION>/${folder}")`);
|
||||
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
},
|
||||
map: {
|
||||
enabled: true,
|
||||
lightStyle: '',
|
||||
darkStyle: '',
|
||||
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
@@ -289,6 +289,23 @@ describe(SystemConfigService.name, () => {
|
||||
expect(config.machineLearning.url).toEqual('immich_machine_learning');
|
||||
});
|
||||
|
||||
const externalDomainTests = [
|
||||
{ should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' },
|
||||
{ should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' },
|
||||
{ should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' },
|
||||
];
|
||||
|
||||
for (const { should, externalDomain, result } of externalDomainTests) {
|
||||
it(`should normalize an external domain ${should}`, async () => {
|
||||
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
|
||||
const partialConfig = { server: { externalDomain } };
|
||||
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
|
||||
|
||||
const config = await sut.getConfig();
|
||||
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
|
||||
});
|
||||
}
|
||||
|
||||
it('should warn for unknown options in yaml', async () => {
|
||||
process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml';
|
||||
const partialConfig = `
|
||||
|
||||
@@ -140,6 +140,23 @@ describe(TagService.name, () => {
|
||||
parent: expect.objectContaining({ id: 'tag-parent' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('should upsert a tag and ignore leading and trailing slashes', async () => {
|
||||
tagMock.getByValue.mockResolvedValueOnce(null);
|
||||
tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent);
|
||||
tagMock.upsertValue.mockResolvedValueOnce(tagStub.child);
|
||||
await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined();
|
||||
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, {
|
||||
value: 'Parent',
|
||||
userId: 'admin_id',
|
||||
parent: undefined,
|
||||
});
|
||||
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, {
|
||||
value: 'Parent/Child',
|
||||
userId: 'admin_id',
|
||||
parent: expect.objectContaining({ id: 'tag-parent' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { Permission } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { JobStatus } from 'src/interfaces/job.interface';
|
||||
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { checkAccess, requireAccess } from 'src/utils/access';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
@@ -138,6 +139,11 @@ export class TagService {
|
||||
return results;
|
||||
}
|
||||
|
||||
async handleTagCleanup() {
|
||||
await this.repository.deleteEmptyTags();
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const tag = await this.repository.get(id);
|
||||
if (!tag) {
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(TrashService.name, () => {
|
||||
let sut: TrashService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let trashMock: Mocked<ITrashRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
@@ -24,11 +26,12 @@ describe(TrashService.name, () => {
|
||||
|
||||
beforeEach(() => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
trashMock = newTrashRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
|
||||
sut = new TrashService(accessMock, assetMock, jobMock, eventMock);
|
||||
sut = new TrashService(accessMock, eventMock, jobMock, trashMock, loggerMock);
|
||||
});
|
||||
|
||||
describe('restoreAssets', () => {
|
||||
@@ -40,44 +43,70 @@ describe(TrashService.name, () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should handle an empty list', async () => {
|
||||
await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 });
|
||||
expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore a batch of assets', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
|
||||
|
||||
await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] });
|
||||
|
||||
expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
|
||||
expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore', () => {
|
||||
it('should handle an empty trash', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(assetMock.restoreAll).not.toHaveBeenCalled();
|
||||
expect(eventMock.clientSend).not.toHaveBeenCalled();
|
||||
trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
trashMock.restore.mockResolvedValue(0);
|
||||
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 });
|
||||
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
|
||||
});
|
||||
|
||||
it('should restore and notify', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
||||
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
|
||||
expect(eventMock.emit).toHaveBeenCalledWith('assets.restore', { assetIds: ['asset-id'], userId: 'user-id' });
|
||||
it('should restore', async () => {
|
||||
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
|
||||
trashMock.restore.mockResolvedValue(1);
|
||||
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
|
||||
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty', () => {
|
||||
it('should handle an empty trash', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
|
||||
trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
trashMock.empty.mockResolvedValue(0);
|
||||
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 });
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should empty the trash', async () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
||||
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
|
||||
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
|
||||
trashMock.empty.mockResolvedValue(1);
|
||||
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
|
||||
expect(trashMock.empty).toHaveBeenCalledWith('user-id');
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAssetsDelete', () => {
|
||||
it('should queue the empty trash job', async () => {
|
||||
await expect(sut.onAssetsDelete()).resolves.toBeUndefined();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueEmptyTrash', () => {
|
||||
it('should queue asset delete jobs', async () => {
|
||||
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false });
|
||||
await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS);
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
|
||||
{
|
||||
name: JobName.ASSET_DELETION,
|
||||
data: { id: 'asset-1', deleteOnDisk: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,69 +1,86 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { OnEmit } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TrashResponseDto } from 'src/dtos/trash.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
|
||||
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ITrashRepository } from 'src/interfaces/trash.interface';
|
||||
import { requireAccess } from 'src/utils/access';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
export class TrashService {
|
||||
constructor(
|
||||
@Inject(IAccessRepository) private access: IAccessRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
) {}
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ITrashRepository) private trashRepository: ITrashRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(TrashService.name);
|
||||
}
|
||||
|
||||
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
|
||||
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<TrashResponseDto> {
|
||||
const { ids } = dto;
|
||||
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
|
||||
await this.restoreAndSend(auth, ids);
|
||||
}
|
||||
|
||||
async restore(auth: AuthDto): Promise<void> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, {
|
||||
trashedBefore: DateTime.now().toJSDate(),
|
||||
}),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
const ids = assets.map((a) => a.id);
|
||||
await this.restoreAndSend(auth, ids);
|
||||
if (ids.length === 0) {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
|
||||
await this.trashRepository.restoreAll(ids);
|
||||
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
|
||||
|
||||
this.logger.log(`Restored ${ids.length} assets from trash`);
|
||||
|
||||
return { count: ids.length };
|
||||
}
|
||||
|
||||
async empty(auth: AuthDto): Promise<void> {
|
||||
async restore(auth: AuthDto): Promise<TrashResponseDto> {
|
||||
const count = await this.trashRepository.restore(auth.user.id);
|
||||
if (count > 0) {
|
||||
this.logger.log(`Restored ${count} assets from trash`);
|
||||
}
|
||||
return { count };
|
||||
}
|
||||
|
||||
async empty(auth: AuthDto): Promise<TrashResponseDto> {
|
||||
const count = await this.trashRepository.empty(auth.user.id);
|
||||
if (count > 0) {
|
||||
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
||||
}
|
||||
return { count };
|
||||
}
|
||||
|
||||
@OnEmit({ event: 'assets.delete' })
|
||||
async onAssetsDelete() {
|
||||
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
||||
}
|
||||
|
||||
async handleQueueEmptyTrash() {
|
||||
let count = 0;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getByUserId(pagination, auth.user.id, {
|
||||
trashedBefore: DateTime.now().toJSDate(),
|
||||
withArchived: true,
|
||||
}),
|
||||
this.trashRepository.getDeletedIds(pagination),
|
||||
);
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for await (const assetIds of assetPagination) {
|
||||
this.logger.debug(`Queueing ${assetIds.length} assets for deletion from the trash`);
|
||||
count += assetIds.length;
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({
|
||||
assetIds.map((assetId) => ({
|
||||
name: JobName.ASSET_DELETION,
|
||||
data: {
|
||||
id: asset.id,
|
||||
id: assetId,
|
||||
deleteOnDisk: true,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreAndSend(auth: AuthDto, ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.logger.log(`Queued ${count} assets for deletion from the trash`);
|
||||
|
||||
await this.assetRepository.restoreAll(ids);
|
||||
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
@@ -93,13 +93,23 @@ export class UserService {
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
||||
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
||||
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
||||
const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path });
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, {
|
||||
profileImagePath: file.path,
|
||||
profileChangedAt: new Date(),
|
||||
});
|
||||
|
||||
if (oldpath !== '') {
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } });
|
||||
}
|
||||
return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
profileImagePath: user.profileImagePath,
|
||||
profileChangedAt: user.profileChangedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteProfileImage(auth: AuthDto): Promise<void> {
|
||||
@@ -107,7 +117,7 @@ export class UserService {
|
||||
if (user.profileImagePath === '') {
|
||||
throw new BadRequestException("Can't delete a missing profile Image");
|
||||
}
|
||||
await this.userRepository.update(auth.user.id, { profileImagePath: '' });
|
||||
await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() });
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
|
||||
import { IViewRepository } from 'src/interfaces/view.interface';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
|
||||
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(ViewService.name, () => {
|
||||
let sut: ViewService;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let viewMock: Mocked<IViewRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
viewMock = newViewRepositoryMock();
|
||||
|
||||
sut = new ViewService(assetMock);
|
||||
sut = new ViewService(viewMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -25,12 +24,12 @@ describe(ViewService.name, () => {
|
||||
describe('getUniqueOriginalPaths', () => {
|
||||
it('should return unique original paths', async () => {
|
||||
const mockPaths = ['path1', 'path2', 'path3'];
|
||||
assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
|
||||
viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
|
||||
|
||||
const result = await sut.getUniqueOriginalPaths(authStub.admin);
|
||||
|
||||
expect(result).toEqual(mockPaths);
|
||||
expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,11 +44,11 @@ describe(ViewService.name, () => {
|
||||
|
||||
const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin }));
|
||||
|
||||
assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
|
||||
viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
|
||||
|
||||
const result = await sut.getAssetsByOriginalPath(authStub.admin, path);
|
||||
expect(result).toEqual(mockAssetReponseDto);
|
||||
await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
|
||||
await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IViewRepository } from 'src/interfaces/view.interface';
|
||||
|
||||
export class ViewService {
|
||||
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
|
||||
constructor(@Inject(IViewRepository) private viewRepository: IViewRepository) {}
|
||||
|
||||
getUniqueOriginalPaths(auth: AuthDto): Promise<string[]> {
|
||||
return this.assetRepository.getUniqueOriginalPaths(auth.user.id);
|
||||
return this.viewRepository.getUniqueOriginalPaths(auth.user.id);
|
||||
}
|
||||
|
||||
async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
|
||||
const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path);
|
||||
|
||||
const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path);
|
||||
return assets.map((asset) => mapAsset(asset, { auth }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const upsertTags = async (repository: ITagRepository, { userId, tags }: U
|
||||
const results: TagEntity[] = [];
|
||||
|
||||
for (const tag of tags) {
|
||||
const parts = tag.split('/');
|
||||
const parts = tag.split('/').filter(Boolean);
|
||||
let parent: TagEntity | undefined;
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
Reference in New Issue
Block a user